It’s fairly common to want to store hierarchical data in a database table. Examples of such data might be categories with unlimited subcategories, data related to a multilevel menu system or a literal representation of hierarchy such as departments in a company.
Relational databases are usually not well suited for storing and retrieving this type of data, but there are a few known techniques that can make them effective for working with multi-level information.
The TreeBehavior helps you maintain a hierarchical data structure in the database that can be queried without much overhead and helps reconstruct the tree data for finding and displaying processes.
This behavior requires the following columns in your table:
parent_id
(nullable) The column holding the ID of the parent row. This column should be indexed.
lft
(integer, signed) Used to maintain the tree structure. This column should be indexed.
rght
(integer, signed) Used to maintain the tree structure.
You can configure the name of those fields should you need to customize them. More information on the meaning of the fields and how they are used can be found in this article describing the MPTT logic
Warning
The TreeBehavior does not support composite primary keys at this point in time.
You enable the Tree behavior by adding it to the Table you want to store hierarchical data in:
class CategoriesTable extends Table
{
public function initialize(array $config): void
{
$this->addBehavior('Tree');
}
}
Once added, you can let CakePHP build the internal structure if the table is already holding some rows:
// In a controller
$categories = $this->getTableLocator()->get('Categories');
$categories->recover();
You can verify it works by getting any row from the table and asking for the count of descendants it has:
$node = $categories->get(1);
echo $categories->childCount($node);
Getting a flat list of the descendants for a node can be done with:
$descendants = $categories->find('children', for: 1);
foreach ($descendants as $category) {
echo $category->name . "\n";
}
If you need to pass conditions you do so as per normal:
$descendants = $categories
->find('children', for: 1)
->where(['name LIKE' => '%Foo%'])
->all();
foreach ($descendants as $category) {
echo $category->name . "\n";
}
If you instead need a threaded list, where children for each node are nested in a hierarchy, you can stack the ‘threaded’ finder:
$children = $categories
->find('children', for: 1)
->find('threaded')
->toArray();
foreach ($children as $child) {
echo "{$child->name} has " . count($child->children) . " direct children";
}
While, if you’re using custom parent_id
you need to pass it in the
‘threaded’ finder option (i.e. parentField
) .
Note
For more information on ‘threaded’ finder options see Finding Threaded Data logic
Traversing threaded results usually requires recursive functions in, but if you
only require a result set containing a single field from each level so you can
display a list, in an HTML select for example, it is better to use the
treeList
finder:
$list = $categories->find('treeList')->toArray();
// In a CakePHP template file:
echo $this->Form->control('categories', ['options' => $list]);
// Or you can output it in plain text, for example in a CLI script
foreach ($list as $categoryName) {
echo $categoryName . "\n";
}
The output will be similar to:
My Categories
_Fun
__Sport
___Surfing
___Skating
_Trips
__National
__International
The treeList
finder takes a number of 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
An example of all options in use is:
$query = $categories->find('treeList',
keyPath: 'url',
valuePath: 'id',
spacer: ' '
);
An example using closure:
$query = $categories->find('treeList',
valuePath: function($entity){
return $entity->url . ' ' . $entity->id
}
);
One very common task is to find the tree path from a particular node to the root of the tree. This is useful, for example, for adding the breadcrumbs list for a menu structure:
$nodeId = 5;
$crumbs = $categories->find('path', for: $nodeId)->all();
foreach ($crumbs as $crumb) {
echo $crumb->name . ' > ';
}
Trees constructed with the TreeBehavior cannot be sorted by any column other
than lft
, this is because the internal representation of the tree depends on
this sorting. Luckily, you can reorder the nodes inside the same level without
having to change their parent:
$node = $categories->get(5);
// Move the node so it shows up one position up when listing children.
$categories->moveUp($node);
// Move the node to the top of the list inside the same level.
$categories->moveUp($node, true);
// Move the node to the bottom.
$categories->moveDown($node, true);
If the default column names that are used by this behavior don’t match your own schema, you can provide aliases for them:
public function initialize(array $config): void
{
$this->addBehavior('Tree', [
'parent' => 'ancestor_id', // Use this instead of parent_id
'left' => 'tree_left', // Use this instead of lft
'right' => 'tree_right' // Use this instead of rght
]);
}
Knowing the depth of tree nodes can be useful when you want to retrieve nodes
only up to a certain level, for example, when generating menus. You can use the
level
option to specify the field that will save level of each node:
$this->addBehavior('Tree', [
'level' => 'level', // Defaults to null, i.e. no level saving
]);
If you don’t want to cache the level using a db field you can use
TreeBehavior::getLevel()
method to get level of a node.
Sometimes you want to persist more than one tree structure inside the same table, you can achieve that by using the ‘scope’ configuration. For example, in a locations table you may want to create one tree per country:
class LocationsTable extends Table
{
public function initialize(array $config): void
{
$this->addBehavior('Tree', [
'scope' => ['country_name' => 'Brazil']
]);
}
}
In the previous example, all tree operations will be scoped to only the rows
having the column country_name
set to ‘Brazil’. You can change the scoping
on the fly by using the ‘config’ function:
$this->behaviors()->Tree->setConfig('scope', ['country_name' => 'France']);
Optionally, you can have a finer grain control of the scope by passing a closure as the scope:
$this->behaviors()->Tree->setConfig('scope', function ($query) {
$country = $this->getConfigureContry(); // A made-up function
return $query->where(['country_name' => $country]);
});
By enabling the cascadeCallbacks
option, TreeBehavior
will load all of
the entities that are going to be deleted. Once loaded, these entities will be
deleted individually using Table::delete()
. This enables ORM callbacks to be
fired when tree nodes are deleted:
$this->addBehavior('Tree', [
'cascadeCallbacks' => true,
]);
By default, recover()
sorts the items using the primary key. This works great
if this is a numeric (auto increment) column, but can lead to weird results if you
use UUIDs.
If you need custom sorting for the recovery, you can set a custom order clause in your config:
$this->addBehavior('Tree', [
'recoverOrder' => ['country_name' => 'DESC'],
]);
When using the Tree behavior, you usually don’t need to worry about the
internal representation of the hierarchical structure. The positions where nodes
are placed in the tree are deduced from the parent_id
column in each of your
entities:
$aCategory = $categoriesTable->get(10);
$aCategory->parent_id = 5;
$categoriesTable->save($aCategory);
Providing inexistent parent ids when saving or attempting to create a loop in the tree (making a node child of itself) will throw an exception.
You can make a node into a root in the tree by setting the parent_id
column to
null:
$aCategory = $categoriesTable->get(10);
$aCategory->parent_id = null;
$categoriesTable->save($aCategory);
Children for the new root node will be preserved.
Deleting a node and all its sub-tree (any children it may have at any depth in the tree) is trivial:
$aCategory = $categoriesTable->get(10);
$categoriesTable->delete($aCategory);
The TreeBehavior will take care of all internal deleting operations for you. It is also possible to only delete one node and re-assign all children to the immediately superior parent node in the tree:
$aCategory = $categoriesTable->get(10);
$categoriesTable->removeFromTree($aCategory);
$categoriesTable->delete($aCategory);
All children nodes will be kept and a new parent will be assigned to them.
The deletion of a node is based off of the lft
and rght
values of the entity. This
is important to note when looping through the various children of a node for
conditional deletes:
$descendants = $teams->find('children', for: 1)->all();
foreach ($descendants as $descendant) {
$team = $teams->get($descendant->id); // search for the up-to-date entity object
if ($team->expired) {
$teams->delete($team); // deletion reorders the lft and rght of database entries
}
}
TreeBehavior will reorder the lft
and rght
values of records in the
table when a node is deleted.
In our example above, the lft
and rght
values of the entities inside
$descendants
will be inaccurate. You will need to reload existing entity
objects if you need an accurate shape of the tree.