loopit-8.x-1.x-dev/src/Aggregate/AggregateArray.php
src/Aggregate/AggregateArray.php
<?php
namespace Drupal\loopit\Aggregate;
use Drupal\loopit\Iterator\AggregateIteratorInterface;
use Drupal\loopit\Iterator\AggregateFilterIterator;
/**
* Nested array as a transform of the recursively traversed / filtered elements.
*
* Traversable by the recursive Agregate Iterator. The nested output is in
* $cacheNested, use Agregate Filter Iterator for filtering.
* Callbacks that are triggered when traversing:
* - ::onCurrent()
* Triggered from the Agregate in the first time call of offsetGet(),
* from the current() iterator method.
* - ::preDown()
* Triggered from the getChildren() iterator method, before the
* instanciation of the children Aggregate.
* - ::onDown()
* Triggered from the children Agregate constructor, instanciated from the
* getChildren() iterator method
* - ::preUp()
* Triggered from the valid() iterator method when there is no valid entry
* and when having a parent Aggrgate
* ::onLeaf()
* Triggered from the hasChildren() iterator method when there are no
* children
*
* When traversing objects, they are replaced with array of their class name and
* hash. Before replacement they are stored in $this->context['objects'] keyed by
* {class name, hash}.
*
* @todo Rename to AggregateBase ?
*/
class AggregateArray implements AggregateInterface {
/**
* Options to define / change the aggregate behavior.
*
* @var array
*/
protected $options = [
'class_key' => '__CLASS__',
'hash_key' => '__HASH__',
'array_parents_key' => '__ARRAY_PARENTS__',
// Depth to limit the recursion. -1 for unlimited depth
'depth' => -1,
// The default iterator class
'iterator_class' => AggregateFilterIterator::class,
];
/**
* "all levels" traversed information.
*
* @var array
*/
protected $context = [
// The traversed path from the root aggregate.
'array_parents' => [],
// The traversed depth from the root aggregate.
'depth' => 0,
// Flat objects references store to avoid infinite loop.
'objects' => [],
];
/**
* Nested cache array used for ouput of the traversed elements.
*
* The traversed nested output can be seen as a transformation of the root
* level aggregate.
* TODO: Rename to $traversedNested or $traversedCache ?
*/
protected $cacheNested = NULL;
/**
* "by level" traversed information
*
* @var array
*/
protected $cache = [];
/**
* The parent level aggregate.
*
* @var \Drupal\loopit\Aggregate\AggregateArray
*/
protected $parent;
/**
* The input data.
*
* @var mixed
*/
protected $input;
/**
* @param array $input
* @param array $options
* @param \Drupal\loopit\Aggregate\AggregateArray $parent
*/
public function __construct($input = [], $options = [], $parent = NULL) {
$this->parent = $parent;
// Validate the iterator_class. Use the default if not valid
if (isset($options['iterator_class'])) {
$iterator_class = $options['iterator_class'];
unset($options['iterator_class']);
$this->setIteratorClass($iterator_class);
}
$this->options = $options + $this->options;
// Notify on down, Also on starting level creation
$input = $this->onDown($input);
$this->input = $input;
}
/**
* Create Aggregate instance, optionally using aggregate_class from $options.
*
* @param array $input
* @param array $options
* @param \Drupal\loopit\Aggregate\AggregateArray $parent
* @return \Drupal\loopit\Aggregate\AggregateArray
*/
public static function createInstance($input = [], $options = [], $parent = NULL) {
$aggregate = NULL;
if (!empty($options['aggregate_class']) && in_array(AggregateInterface::class, class_implements($options['aggregate_class']))) {
$aggregate = $options['aggregate_class'];
}
if (isset($aggregate)) {
$aggreg = new $aggregate($input, $options, $parent);
}
else {
$aggreg = new static ($input, $options, $parent);
}
return $aggreg;
}
/**
* For ArrayObject similarity
*/
public function getIteratorClass() {
return $this->options['iterator_class'];
}
/**
* Called from the constructor for iterator_class init.
*
* For ArrayObject similarity.
*/
public function setIteratorClass($iterator_class) {
// subtype/interface of Drupal\loopit\Iterator\AggregateIterator;
if (in_array(AggregateIteratorInterface::class, class_implements($iterator_class))) {
$this->options['iterator_class'] = $iterator_class;
}
}
public function getIterator() {
$iterator = $this->options['iterator_class'];
return new $iterator($this);
}
/**
* {@inheritDoc}
*
* @see ArrayAccess::offsetExists()
*/
public function offsetExists($index) {
return array_key_exists($index, $this->input);
}
/**
* {@inheritDoc}
*
* @see ArrayAccess::offsetGet()
*/
public function offsetGet($index) {
// Use cache so that onCurrent() callback is called only once.
if (array_key_exists($index, $this->cache)) {
return $this->cache[$index];
}
$current = $this->input[$index];
$depth_limit = $this->options['depth'] >= 0 && count($this->context['array_parents']) >= $this->options['depth'];
// Limit before onCurrent processing
if ($depth_limit) {
// TODO: if object cannot be converted to string
$current = @(string)$current;
}
// Here is $current processing
$current = $this->onCurrent($current, $index);
// Limit also after onCurrent processing, because potential new children
// from onCurrent() callback.
if ($depth_limit) {
// TODO: if object cannot be converted to string
$current = @(string)$current;
}
$this->cache[$index] = $current;
return $current;
}
/**
* Read only array acces.
*
* Use onCurrent() callbacks to change the current value in the traversed
* nested output. For Aggregate transformations use the transform() method
* called in self::onDown() event handler (into the next level as a new
* aggregate instance).
*
* @see ArrayAccess::offsetSet()
*/
public function offsetSet($offset, $value) { }
/**
* Read only array acces.
*
* Use onCurrent() callbacks with AggregateFilterIterator. The element will
* be filtered out in the traversed nested output for empty callback return.
*
* @see ArrayAccess::offsetUnset()
*/
public function offsetUnset($offset) { }
/**
* Getter for the $options attribute.
*/
public function getOptions() {
return $this->options;
}
/**
* Getter for the $context attribute.
*/
public function getContext() {
return $this->context;
}
/**
* Getter for the array_parents key in the $context attribute.
*/
public function getArrayParents() {
return $this->context['array_parents'];
}
/**
* Getter for the depth key in the $context attribute.
*/
public function getDepth() {
return $this->context['depth'];
}
/**
* Getter for the $cacheNested attribute.
*/
public function getCacheNested() {
return $this->cacheNested;
}
/**
* Getter for the $cache attribute.
*/
public function getCache() {
return $this->cache;
}
/**
* Getter for the $parent attribute.
*/
public function getParent() {
return $this->parent;
}
/**
* Getter for the $input attribute.
*/
public function getInput() {
return $this->input;
}
/**
* Callback for the current element.
*/
public function onCurrent($current, $index) {
return $this->callbackProcessValue(__FUNCTION__, $current, $index);
}
/**
* Notify event handlers by using simple callback.
*
* For performance do not use events dispatching, just check if the event
* handler is callable. For static callbacks pass $this as the first parameter
*
* @param string $function
* @param mixed $current
* @param string $index
*/
public function callbackProcessValue($function, $current, $index) {
// There are callback to be called
if (isset($this->options[$function])) {
foreach ($this->options[$function] as $key => $callback) {
if (is_string($callback) && strpos($callback, '::') === 0) {
$callback = static::class . $callback;
}
if (is_callable($callback)) {
$args = [$current, $index];
if (is_string($callback)) {
array_unshift($args, $this);
}
$current = call_user_func_array($callback, $args);
}
}
}
return $current;
}
/**
* Callback before to go in the children level.
*
* Add the current key as entry in "array parents" option
*
* @param string $key
*/
public function preDown($key) {
$this->context['array_parents'][] = $key;
// One level deeper
$this->context['depth']++;
}
/**
* Callback when already in children level.
*
* The child aggregate is responsible to define how it is traversable (how it
* exposes his elements) via the transform method.
*
* @param mixed $aggregate The traversable input. Can be an array or object.
* @return mixed
* The same or altered input
*/
public function onDown($aggregate) {
// This context is by reference of the parent one. Context is share between
// all $aggregate levels
if (isset($this->parent)) {
$this->context = &$this->parent->context;
}
$aggregate = $this->transform($aggregate);
return $aggregate;
}
/**
* Traversal transform / stop method.
*
* This return value will be used as input for the aggregate.
* Empty or scalar return value can be used for "stop recursion" condition.
*
* Store traversed objects in $this->context['objects'] keyed by
* {class name, hash}. When traversed twice or more then add the path
* (array parents) when have been met for the first time.
*
* @param mixed $obj
* @return The class and hash if object, as is else.
*/
public function transform($obj) {
if (\is_object($obj) || $obj instanceof \__PHP_Incomplete_Class) {
$class = \get_class($obj);
$h = \spl_object_hash($obj);
$aggregate = [
$this->options['class_key'] => $class,
$this->options['hash_key'] => $h,
];
// First time met object
if (empty($this->context['objects'][$class][$h]['obj'])) {
$this->context['objects'][$class][$h] = [
'obj' => $obj, //$reflexion;
'array_parents' => $this->context['array_parents'],
'count' => 1,
];
}
// Already traversed so add the path (array parents) when have been met
// for the first time
else {
$aggregate[
$this->options['array_parents_key']
] = $this->context['objects'][$class][$h]['array_parents']
;
$this->context['objects'][$class][$h]['count']++;
}
}
else {
$aggregate = $obj;
}
return $aggregate;
}
/**
* Callback before to go back to the parent level.
*
* Save this level cacheNested to the parent one at the $parent_key index.
*/
public function preUp() {
$parent_key = array_pop($this->context['array_parents']);
// Do not create entry if there is no traversed element from $this->cacheNested
if (isset($this->cacheNested)) {
$this->parent->cacheNested[$parent_key] = $this->cacheNested;
}
$this->context['depth']--;
return $parent_key;
}
/**
* Callback when the current is a leaf.
*
* Store in nested cache the traversed elements.
*
* @param string $key The index of the current traversed element
*/
public function onLeaf($current, $index) {
$current = $this->callbackProcessValue(__FUNCTION__, $current, $index);
$this->cacheNested[$index] = $current;
return $current;
}
/**
* Traverse without iterators and without any filter or value callbacks.
*
* Same functionnality than using iterator but simplified and faster
* Only level changing related callbacks for array parents, depth and recursion marker
*
* @see http://php.net/manual/en/class.recursiveiteratoriterator.php#112713
* @param mixed $aggregate
* @return mixed The traversed elements in nested output
*/
public function traverseFast($aggregate = NULL) {
if (!isset($aggregate)) {
$aggregate = $this->input;
}
$aggregate = $this->onDown($aggregate);
// TODO: use $this->cacheNested instead of $output ?
$output = [];
// TODO: To benchmark performance using offsetGet() (which includes onCurrent() callbacks).
// TODO: If will not use offsetGet() then get working the depth option
$current = reset($aggregate);
$key = key($aggregate);
// Equivalent to: while ($iterator->valid())
while (isset($key)) {
// Equivalent to: if ($iterator->hasChildren())
if (\is_array($current) || \is_object($current) || $current instanceof \__PHP_Incomplete_Class) {
$this->preDown($key);
// Equivalent to: $iterator->getChildren();
$output[$key] = self::traverseFast($current);
}
else {
// For getting leaf values
// TODO: To benchmark using onleaf() callbacks
$output[$key] = $current;
}
// Equivalent to: $iterator->next();
$current = next($aggregate);
$key = key($aggregate);
}
$this->preUp();
return $output;
}
}