silverstripe-framework/src/ORM/Hierarchy/MarkedSet.php

813 lines
23 KiB
PHP

<?php
namespace SilverStripe\ORM\Hierarchy;
use InvalidArgumentException;
use LogicException;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\SS_List;
use SilverStripe\View\ArrayData;
/**
* Contains a set of hierarchical objects generated from a marking compilation run.
*
* A set of nodes can be "marked" for later export, in order to prevent having to
* export the entire contents of a potentially huge tree.
*/
class MarkedSet
{
use Injectable;
/**
* Marked nodes for a given subtree. The first item in this list
* is the root object of the subtree.
*
* A marked item is an item in a tree which will be included in
* a resulting tree.
*
* @var array Map of [itemID => itemInstance]
*/
protected $markedNodes;
/**
* Optional filter callback for filtering nodes to mark
*
* Array with keys:
* - parameter
* - value
* - func
*
* @var array
* @temp made public
*/
public $markingFilter;
/**
* @var DataObject
*/
protected $rootNode = null;
/**
* Method to use for getting children. Defaults to 'AllChildrenIncludingDeleted'
*
* @var string
*/
protected $childrenMethod = null;
/**
* Method to use for counting children. Defaults to `numChildren`
*
* @var string
*/
protected $numChildrenMethod = null;
/**
* Minimum number of nodes to iterate over before stopping recursion
*
* @var int
*/
protected $nodeCountThreshold = null;
/**
* Max number of nodes to return from a single children collection
*
* @var int
*/
protected $maxChildNodes;
/**
* Enable limiting
*
* @var bool
*/
protected $enableLimiting = true;
/**
* Create an empty set with the given class
*
* @param DataObject $rootNode Root node for this set. To collect the entire tree,
* pass in a singelton object.
* @param string $childrenMethod Override children method
* @param string $numChildrenMethod Override children counting method
* @param int $nodeCountThreshold Minimum threshold for number nodes to mark
* @param int $maxChildNodes Maximum threshold for number of child nodes to include
*/
public function __construct(
DataObject $rootNode,
$childrenMethod = null,
$numChildrenMethod = null,
$nodeCountThreshold = null,
$maxChildNodes = null
) {
if (! $rootNode::has_extension(Hierarchy::class)) {
throw new InvalidArgumentException(
get_class($rootNode) . " does not have the Hierarchy extension"
);
}
$this->rootNode = $rootNode;
if ($childrenMethod) {
$this->setChildrenMethod($childrenMethod);
}
if ($numChildrenMethod) {
$this->setNumChildrenMethod($numChildrenMethod);
}
if ($nodeCountThreshold) {
$this->setNodeCountThreshold($nodeCountThreshold);
}
if ($maxChildNodes) {
$this->setMaxChildNodes($maxChildNodes);
}
}
/**
* Get total number of nodes to get. This acts as a soft lower-bounds for
* number of nodes to search until found.
* Defaults to value of node_threshold_total of hierarchy class.
*
* @return int
*/
public function getNodeCountThreshold()
{
return $this->nodeCountThreshold
?: $this->rootNode->config()->get('node_threshold_total');
}
/**
* Max number of nodes that can be physically rendered at any level.
* Acts as a hard upper bound, after which nodes will be trimmed for
* performance reasons.
*
* @return int
*/
public function getMaxChildNodes()
{
return $this->maxChildNodes
?: $this->rootNode->config()->get('node_threshold_leaf');
}
/**
* Set hard limit of number of nodes to get for this level
*
* @param int $count
* @return $this
*/
public function setMaxChildNodes($count)
{
$this->maxChildNodes = $count;
return $this;
}
/**
* Set max node count
*
* @param int $total
* @return $this
*/
public function setNodeCountThreshold($total)
{
$this->nodeCountThreshold = $total;
return $this;
}
/**
* Get method to use for getting children
*
* @return string
*/
public function getChildrenMethod()
{
return $this->childrenMethod ?: 'AllChildrenIncludingDeleted';
}
/**
* Get children from this node
*
* @param DataObject $node
* @return SS_List
*/
protected function getChildren(DataObject $node)
{
$method = $this->getChildrenMethod();
return $node->$method() ?: ArrayList::create();
}
/**
* Set method to use for getting children
*
* @param string $method
* @throws InvalidArgumentException
* @return $this
*/
public function setChildrenMethod($method)
{
// Check method is valid
if (!$this->rootNode->hasMethod($method)) {
throw new InvalidArgumentException(sprintf(
"Can't find the method '%s' on class '%s' for getting tree children",
$method,
get_class($this->rootNode)
));
}
$this->childrenMethod = $method;
return $this;
}
/**
* Get method name for num children
*
* @return string
*/
public function getNumChildrenMethod()
{
return $this->numChildrenMethod ?: 'numChildren';
}
/**
* Count children
*
* @param DataObject $node
* @return int
*/
protected function getNumChildren(DataObject $node)
{
$method = $this->getNumChildrenMethod();
return (int)$node->$method();
}
/**
* Set method name to get num children
*
* @param string $method
* @return $this
*/
public function setNumChildrenMethod($method)
{
// Check method is valid
if (!$this->rootNode->hasMethod($method)) {
throw new InvalidArgumentException(sprintf(
"Can't find the method '%s' on class '%s' for counting tree children",
$method,
get_class($this->rootNode)
));
}
$this->numChildrenMethod = $method;
return $this;
}
/**
* Returns the children of this DataObject as an XHTML UL. This will be called recursively on each child, so if they
* have children they will be displayed as a UL inside a LI.
*
* @param string $template Template for items in the list
* @param array|callable $context Additional arguments to add to template when rendering
* due to excessive line length. If callable, this will be executed with the current node dataobject
* @return string
*/
public function renderChildren(
$template = null,
$context = []
) {
// Default to HTML template
if (!$template) {
$template = [
'type' => 'Includes',
self::class . '_HTML'
];
}
$tree = $this->getSubtree($this->rootNode, 0);
$node = $this->renderSubtree($tree, $template, $context);
return (string)$node->getField('SubTree');
}
/**
* Get child data formatted as JSON
*
* @param callable $serialiseEval A callback that takes a DataObject as a single parameter,
* and should return an array containing a simple array representation. This result will
* replace the 'node' property at each point in the tree.
* @return array
*/
public function getChildrenAsArray($serialiseEval = null)
{
if (!$serialiseEval) {
$serialiseEval = function ($data) {
/** @var DataObject $node */
$node = $data['node'];
return [
'id' => $node->ID,
'title' => $node->getTitle()
];
};
}
$tree = $this->getSubtree($this->rootNode, 0);
return $this->getSubtreeAsArray($tree, $serialiseEval);
}
/**
* Render a node in the tree with the given template
*
* @param array $data array data for current node
* @param string|array $template Template to use
* @param array|callable $context Additional arguments to add to template when rendering
* due to excessive line length. If callable, this will be executed with the current node dataobject
* @return ArrayData Viewable object representing the root node. use getField('SubTree') to get HTML
*/
protected function renderSubtree($data, $template, $context = [])
{
// Render children
$childNodes = new ArrayList();
foreach ($data['children'] as $child) {
$childData = $this->renderSubtree($child, $template, $context);
$childNodes->push($childData);
}
// Build parent node
$parentNode = new ArrayData($data);
$parentNode->setField('children', $childNodes); // Replace raw array with template-friendly list
$parentNode->setField('markingClasses', $this->markingClasses($data['node']));
// Evaluate custom context
if (is_callable($context)) {
$context = call_user_func($context, $data['node']);
}
if ($context) {
foreach ($context as $key => $value) {
$parentNode->setField($key, $value);
}
}
// Render
$subtree = $parentNode->renderWith($template);
$parentNode->setField('SubTree', $subtree);
return $parentNode;
}
/**
* Return sub-tree as json array
*
* @param array $data
* @param callable $serialiseEval A callback that takes a DataObject as a single parameter,
* and should return an array containing a simple array representation. This result will
* replace the 'node' property at each point in the tree.
* @return mixed|string
*/
protected function getSubtreeAsArray($data, $serialiseEval)
{
$output = $data;
// Serialise node
$output['node'] = $serialiseEval($data['node']);
// Force serialisation of DBField instances
if (is_array($output['node'])) {
foreach ($output['node'] as $key => $value) {
if ($value instanceof DBField) {
$output['node'][$key] = $value->getSchemaValue();
}
}
} elseif ($output['node'] instanceof DBField) {
$output['node'] = $output['node']->getSchemaValue();
}
// Replace children with serialised elements
$output['children'] = [];
foreach ($data['children'] as $child) {
$output['children'][] = $this->getSubtreeAsArray($child, $serialiseEval);
}
return $output;
}
/**
* Get tree data for node
*
* @param DataObject $node
* @param int $depth
* @return array|string
*/
protected function getSubtree($node, $depth = 0)
{
// Check if this node is limited due to child count
$numChildren = $this->getNumChildren($node);
$limited = $this->isNodeLimited($node, $numChildren);
// Build root rode
$expanded = $this->isExpanded($node);
$opened = $this->isTreeOpened($node);
$output = [
'node' => $node,
'marked' => $this->isMarked($node),
'expanded' => $expanded,
'opened' => $opened,
'depth' => $depth,
'count' => $numChildren, // Count of DB children
'limited' => $limited, // Flag whether 'items' has been limited
'children' => [], // Children to return in this request
];
// Don't iterate children if past limit
// or not expanded (requires subsequent request to get)
if ($limited || !$expanded) {
return $output;
}
// Get children
$children = $this->getChildren($node);
foreach ($children as $child) {
// Recurse
if ($this->isMarked($child)) {
$output['children'][] = $this->getSubtree($child, $depth + 1);
}
}
return $output;
}
/**
* Mark a segment of the tree, by calling mark().
*
* The method performs a breadth-first traversal until the number of nodes is more than minCount. This is used to
* get a limited number of tree nodes to show in the CMS initially.
*
* This method returns the number of nodes marked. After this method is called other methods can check
* {@link isExpanded()} and {@link isMarked()} on individual nodes.
*
* @return $this
*/
public function markPartialTree()
{
$nodeCountThreshold = $this->getNodeCountThreshold();
// Add root node, not-expanded by default
/** @var DataObject|Hierarchy $rootNode */
$rootNode = $this->rootNode;
$this->clearMarks();
$this->markUnexpanded($rootNode);
// Build markedNodes for this subtree until we reach the threshold
// foreach can't handle an ever-growing $nodes list
while (list(, $node) = each($this->markedNodes)) {
$children = $this->markChildren($node);
if ($nodeCountThreshold && sizeof($this->markedNodes) > $nodeCountThreshold) {
// Undo marking children as opened since they're lazy loaded
/** @var DataObject|Hierarchy $child */
foreach ($children as $child) {
$this->markClosed($child);
}
break;
}
}
return $this;
}
/**
* Filter the marking to only those object with $node->$parameterName == $parameterValue
*
* @param string $parameterName The parameter on each node to check when marking.
* @param mixed $parameterValue The value the parameter must be to be marked.
* @return $this
*/
public function setMarkingFilter($parameterName, $parameterValue)
{
$this->markingFilter = array(
"parameter" => $parameterName,
"value" => $parameterValue
);
return $this;
}
/**
* Filter the marking to only those where the function returns true. The node in question will be passed to the
* function.
*
* @param callable $callback Callback to filter
* @return $this
*/
public function setMarkingFilterFunction($callback)
{
$this->markingFilter = array(
"func" => $callback,
);
return $this;
}
/**
* Returns true if the marking filter matches on the given node.
*
* @param DataObject $node Node to check
* @return bool
*/
protected function markingFilterMatches(DataObject $node)
{
if (!$this->markingFilter) {
return true;
}
// Func callback filter
if (isset($this->markingFilter['func'])) {
$func = $this->markingFilter['func'];
return call_user_func($func, $node);
}
// Check object property filter
if (isset($this->markingFilter['parameter'])) {
$parameterName = $this->markingFilter['parameter'];
$value = $this->markingFilter['value'];
if (is_array($value)) {
return in_array($node->$parameterName, $value);
} else {
return $node->$parameterName == $value;
}
}
throw new LogicException("Invalid marking filter");
}
/**
* Mark all children of the given node that match the marking filter.
*
* @param DataObject $node Parent node
* @return array List of children marked by this operation
*/
protected function markChildren(DataObject $node)
{
$this->markExpanded($node);
// If too many children leave closed
if ($this->isNodeLimited($node)) {
// Limited nodes are always expanded
$this->markClosed($node);
return [];
}
// Iterate children if not limited
$children = $this->getChildren($node);
if (!$children) {
return [];
}
// Mark all children
$markedChildren = [];
foreach ($children as $child) {
$markingMatches = $this->markingFilterMatches($child);
if (!$markingMatches) {
continue;
}
// Mark a child node as unexpanded if it has children and has not already been expanded
if ($this->getNumChildren($child) > 0 && !$this->isExpanded($child)) {
$this->markUnexpanded($child);
} else {
$this->markExpanded($child);
}
$markedChildren[] = $child;
}
return $markedChildren;
}
/**
* Return CSS classes of 'unexpanded', 'closed', both, or neither, as well as a 'jstree-*' state depending on the
* marking of this DataObject.
*
* @param DataObject $node
* @return string
*/
protected function markingClasses($node)
{
$classes = [];
if (!$this->isExpanded($node)) {
$classes[] = 'unexpanded';
}
// Set jstree open state, or mark it as a leaf (closed) if there are no children
if (!$this->getNumChildren($node)) {
// No children
$classes[] = "jstree-leaf closed";
} elseif ($this->isTreeOpened($node)) {
// Open with children
$classes[] = "jstree-open";
} else {
// Closed with children
$classes[] = "jstree-closed closed";
}
return implode(' ', $classes);
}
/**
* Mark the children of the DataObject with the given ID.
*
* @param int $id ID of parent node
* @param bool $open If this is true, mark the parent node as opened
* @return bool
*/
public function markById($id, $open = false)
{
if (isset($this->markedNodes[$id])) {
$this->markChildren($this->markedNodes[$id]);
if ($open) {
$this->markOpened($this->markedNodes[$id]);
}
return true;
} else {
return false;
}
}
/**
* Expose the given object in the tree, by marking this page and all it ancestors.
*
* @param DataObject|Hierarchy $childObj
* @return $this
*/
public function markToExpose(DataObject $childObj)
{
if (!$childObj) {
return $this;
}
$stack = $childObj->getAncestors(true)->reverse();
foreach ($stack as $stackItem) {
$this->markById($stackItem->ID, true);
}
return $this;
}
/**
* Return the IDs of all the marked nodes.
*
* @refactor called from CMSMain
* @return array
*/
public function markedNodeIDs()
{
return array_keys($this->markedNodes);
}
/**
* Cache of DataObjects' expanded statuses: [ClassName][ID] = bool
* @var array
*/
protected $expanded = [];
/**
* Cache of DataObjects' opened statuses: [ID] = bool
* @var array
*/
protected $treeOpened = [];
/**
* Reset marked nodes
*/
public function clearMarks()
{
$this->markedNodes = [];
$this->expanded = [];
$this->treeOpened = [];
}
/**
* Mark this DataObject as expanded.
*
* @param DataObject $node
* @return $this
*/
public function markExpanded(DataObject $node)
{
$id = $node->ID ?: 0;
$this->markedNodes[$id] = $node;
$this->expanded[$id] = true;
return $this;
}
/**
* Mark this DataObject as unexpanded.
*
* @param DataObject $node
* @return $this
*/
public function markUnexpanded(DataObject $node)
{
$id = $node->ID ?: 0;
$this->markedNodes[$id] = $node;
unset($this->expanded[$id]);
return $this;
}
/**
* Mark this DataObject's tree as opened.
*
* @param DataObject $node
* @return $this
*/
public function markOpened(DataObject $node)
{
$id = $node->ID ?: 0;
$this->markedNodes[$id] = $node;
$this->treeOpened[$id] = true;
return $this;
}
/**
* Mark this DataObject's tree as closed.
*
* @param DataObject $node
* @return $this
*/
public function markClosed(DataObject $node)
{
$id = $node->ID ?: 0;
$this->markedNodes[$id] = $node;
unset($this->treeOpened[$id]);
return $this;
}
/**
* Check if this DataObject is marked.
*
* @param DataObject $node
* @return bool
*/
public function isMarked(DataObject $node)
{
$id = $node->ID ?: 0;
return !empty($this->markedNodes[$id]);
}
/**
* Check if this DataObject is expanded.
* An expanded object has had it's children iterated through.
*
* @param DataObject $node
* @return bool
*/
public function isExpanded(DataObject $node)
{
$id = $node->ID ?: 0;
return !empty($this->expanded[$id]);
}
/**
* Check if this DataObject's tree is opened.
* This is an expanded node which also should have children visually shown.
*
* @param DataObject $node
* @return bool
*/
public function isTreeOpened(DataObject $node)
{
$id = $node->ID ?: 0;
return !empty($this->treeOpened[$id]);
}
/**
* Check if this node has too many children
*
* @param DataObject|Hierarchy $node
* @param int $count Children count (if already calculated)
* @return bool
*/
protected function isNodeLimited(DataObject $node, $count = null)
{
// Singleton root node isn't limited
if (!$node->ID) {
return false;
}
// Check if limiting is enabled first
if (!$this->getLimitingEnabled()) {
return false;
}
// Count children for this node and compare to max
if (!isset($count)) {
$count = $this->getNumChildren($node);
}
return $count > $this->getMaxChildNodes();
}
/**
* Toggle limiting on or off
*
* @param bool $enabled
* @return $this
*/
public function setLimitingEnabled($enabled)
{
$this->enableLimiting = $enabled;
return $this;
}
/**
* Check if limiting is enabled
*
* @return bool
*/
public function getLimitingEnabled()
{
return $this->enableLimiting;
}
}