<?php
/*
* Copyright (C) 2017 Karmabunny Pty Ltd.
*
* This file is a part of SproutCMS.
*
* SproutCMS is free software: you can redistribute it and/or modify it under the terms
* of the GNU General Public License as published by the Free Software Foundation, either
* version 2 of the License, or (at your option) any later version.
*
* For more information, visit <http://getsproutcms.com>.
*/
namespace Sprout\Helpers;
use ArrayAccess;
use Exception;
/**
* @package Tree
**/
/**
* Represents a node in the tree
**/
class Treenode implements ArrayAccess
{
protected $data = array(); private $real_children = array(); private $filtered_children = null;
public $parent = null;
/**
* Creates a node, with the specified data
*
* @param array $data Initial data for the node
**/
public function __construct($data = null)
{
if (! $data) return;
foreach ($data as $key => $value) {
$this->data[$key] = $value;
}
}
/**
* Returns true if this node is the root node
**/
public function isRoot()
{
if ($this->parent) return false;
return true;
}
/* TREE LOADING ----------------------------------------------*/
/**
* Loads a flat table into a tree structure.
* Requires the table to have an id, parent_id and record_order field.
*
* @param string $table The name of the table to load
* @param array $conditions Query conditions, in the format used by {@see Pdb::buildClause}
* @param string $order The column to order by. Defaults to 'record_order'
* @return Treenode The loaded tree
**/
public static
function loadTree
($table, array $conditions = ['1'], $order = 'record_order') {
Pdb::validateIdentifier($table);
Pdb::validateIdentifier($order);
$where = Pdb::buildClause($conditions, $binds);
$q = "SELECT *
FROM ~{$table}
WHERE {$where}
ORDER BY parent_id, {$order}";
$res = Pdb::query($q, $binds, 'pdo');
foreach ($res as $row) {
$nodes[] = new Treenode($row);
}
$res->closeCursor();
$root_node = new Treenode
(array('id' => 0));
// Assign nodes to their parents
do {
$num_processed = 0;
foreach ($nodes as $index => $node) {
$parent = $root_node->findNodeValue('id', $node['parent_id']);
if ($parent) {
$parent->children[] = $node;
$node->parent = $parent;
unset ($orphans[$node['id']]); $num_processed++;
} else {
$orphans[$node['id']] = $node;
}
}
if ($num_processed == 0) break;
return $root_node;
}
/* TREE SEARCHING --------------------------------------------*/
/**
* Finds a node by looking at this node
* If that does not match, looking at each of this nodes children in turn.
* If that does not match, return null
*
* @param mixed $key The key to search for in the data
* @param mixed $value The value to search for in the data
* @return TreeNode if found, null if not found.
**/
public function findNodeValue($key, $value)
{
if ($this->data[$key] == $value) {
return $this;
}
foreach ($this->children as $child) {
$res = $child->findNodeValue ($key, $value);
if ($res) return $res;
}
return null;
}
/**
* Finds a node by looking at this node
* If that does not match, looking at each of this nodes children in turn.
* If that does not match, return null
*
* @param TreenodeMatcher $matcher The matcher to use for finding the node.
* @return static if found, null if not found.
**/
public function findNode(TreenodeMatcher $matcher)
{
if ($matcher->match($this)) {
return $this;
}
$res = $matcher->descend($this);
if (! $res) return null;
foreach ($this->children as $child) {
$res = $child->findNode ($matcher);
if ($res) return $res;
}
$matcher->ascend($this);
return null;
}
/**
* Finds all nodes which match the specified matcher.
*
* @param TreenodeMatcher $matcher The matcher to use for finding the nodes.
* @return static[] The found nodes, or an empty array if no nodes were found.
**/
public function findAllNodes(TreenodeMatcher $matcher)
{
if ($matcher->match($this)) {
$nodes[] = $this;
}
$res = $matcher->descend($this);
if (! $res) return $nodes;
foreach ($this->children as $child) {
$res = $child->findAllNodes ($matcher);
if ($res) {
}
}
$matcher->ascend($this);
return $nodes;
}
/**
* Finds all ancestors of a specific node, including the node, not including the root node of the tree
* The array is in order from top to bottom, so $ancestors[0] is the top-parent, and $ancestors[len-1] is the node.
*
* A
* B C
* D E F G
* H I
*
* NODE RESULT ARRAY
* H B D H
* D B D
* B B
* A (empty)
*
* @return static[] The ancestors, as described above.
**/
public function findAncestors()
{
$node = $this;
while ($node->data['id'] != 0) {
$ancestors[] = $node;
$node = $node->parent;
}
return $ancestors;
}
/**
* Filter the children of this node, removing any children which don't match the specified TreenodeMatcher.
* The nodes are not actually removed, matching nodes are just added to a filtered children list
* Which is returned instead of the original children list.
**/
public function filterChildren(TreenodeMatcher $matcher)
{
$this->filtered_children = array();
$res = $matcher->descend($this);
if (! $res) return;
foreach ($this->real_children as $node) {
if ($matcher->match($node)) {
$this->filtered_children[] = $node;
$node->filterChildren($matcher);
}
}
$matcher->ascend($this);
}
/**
* Removes the currently active filter
**/
public function removeFilter()
{
$this->filtered_children = null;
foreach ($this->real_children as $node) {
$node->removeFilter();
}
}
/**
* Returns an array of all children nodes, including sub-children
*
* The array will be id => name.
* This function requires the table to have a column named 'name'.
* The name field will be indented according to the depth.
* If specified, the exclude_id argument will be used to
* exclude nodes (and their children) according to id.
**/
public function getAllChildren($exclude_id = null, $indent_str = ' ', $indent = 0)
{
foreach ($this->children as $node) {
if ($exclude_id == $node->data['id']) continue;
$output[$node->data['id']] = str_repeat($indent_str, $indent) . $node->data['name'];
$children = $node->getAllChildren($exclude_id, $indent_str, $indent + 1);
foreach ($children as $id => $name) {
$output[$id] = $name;
}
}
return $output;
}
/**
*
* @return static[]
*/
public function & getChildren()
{
if ($this->filtered_children !== null) {
return $this->filtered_children;
}
return $this->real_children;
}
/**
* Is this node an orphan?
* Orphans are at the top of the tree, and they don't have any children.
*
* @return bool if it's an orphan, false otherwise
**/
public function isOrphan()
{
if ($this->parent and !$this->parent->isRoot()) return false;
if (count($this->children) != 0) return false;
return true;
}
/* NODE DATA -------------------------------------------------*/
/**
* Generic field getter for unknown properties - used for TreeNode->children
*
* Be aware that data gotten through this getter is BY REFERENCE
* For by-value data retrival, use the array-access functions.
*
* @param string $field The field to get.
**/
public function &__get($field) {
if ($field == 'children') {
return $this->getChildren();
}
throw new Exception("Invalid usage of \$node->__get() for field '{$field}'; use array methods (\$node['{$field}']) instead.");
}
/**
* Generic field getter for unknown properties - used for TreeNode->children
*
* @param string $field The field to set.
* @param mixed $value The value to set.
**/
public function __set($field, $value)
{
if ($field == 'children') {
$this->real_children = $value;
return;
}
throw new Exception("Invalid usage of \$node->__set() for field '{$field}'; use array methods (\$node['{$field}']) instead.");
}
/**
* ArrayAccess function for checking if a specified key exists
* @param mixed $offset The offset to check.
**/
public function offsetExists($offset)
{
return isset($this->data[$offset]); }
/**
* ArrayAccess function for getting a value by its key
* @param mixed $offset The offset to get.
**/
public function offsetGet($offset)
{
return $this->data[$offset];
}
/**
* ArrayAccess function for setting a value by its key
* @param mixed $offset The offset to set.
* @param mixed $value The value to set.
**/
public function offsetSet($offset, $value)
{
$this->data[$offset] = $value;
}
/**
* ArrayAccess function for unsetting a value
* @param mixed $offset The offset to remove.
**/
public function offsetUnset($offset)
{
unset ($this->data[$offset]); }
}