Browse Source

Merge pull request #3257 from cakephp/3.0-tree-iterator

3.0 tree iterator
José Lorenzo Rodríguez 12 years ago
parent
commit
68a378eae8

+ 54 - 0
src/Collection/CollectionTrait.php

@@ -21,8 +21,10 @@ use Cake\Collection\Iterator\ExtractIterator;
 use Cake\Collection\Iterator\FilterIterator;
 use Cake\Collection\Iterator\InsertIterator;
 use Cake\Collection\Iterator\MapReduce;
+use Cake\Collection\Iterator\NestIterator;
 use Cake\Collection\Iterator\ReplaceIterator;
 use Cake\Collection\Iterator\SortIterator;
+use Cake\Collection\Iterator\TreeIterator;
 use LimitIterator;
 
 /**
@@ -857,4 +859,56 @@ trait CollectionTrait {
 		return new Collection($this->toArray($preserveKeys));
 	}
 
+/**
+ * Returns a new collection with each of the elements of this collection
+ * after flattening the tree structure. The tree structure is defined
+ * by nesting elements under a key with a known name. It is possible
+ * to specify such name by using the '$nestingKey' parameter.
+ *
+ * By default all elements in the tree following a Depth First Search
+ * will be returned, that is, elements from the top parent to the leaves
+ * for each branch.
+ *
+ * It is possible to return all elements from bottom to top using a Breadth First
+ * Search approach by passing the '$dir' parameter with 'asc'. That is, it will
+ * return all elements for the same tree depth first and from bottom to top.
+ *
+ * Finally, you can specify to only get a collection with the leaf nodes in the
+ * tree structure. You do so by passing 'leaves' in the first argument.
+ *
+ * The possible values for the first argument are aliases for the following
+ * constants and it is valid to pass those instead of the alias:
+ *
+ * - desc: TreeIterator::SELF_FIRST
+ * - asc: TreeIterator::CHILD_FIRST
+ * - leaves: TreeIterator::LEAVES_ONLY
+ *
+ * ### Example:
+ *
+ * {{{
+ * $collection = new Collection([
+ *	['id' => 1, 'children' => [['id' => 2, 'children' => [['id' => 3]]]]],
+ *	['id' => 4, 'children' => [['id' => 5]]]
+ * ]);
+ * $flattenedIds = $collection->listNested()->extract('id'); // Yields [1, 2, 3, 4, 5]
+ * }}}
+ *
+ * @param string|integer $dir The direction in which to return the elements
+ * @param string|callable $nestingKey The key name under which children are nested
+ * or a callable function that will return the children list
+ * @return \Cake\Collection\Iterator\TreeIterator
+ */
+	public function listNested($dir = 'desc', $nestingKey = 'children') {
+		$dir = strtolower($dir);
+		$modes = [
+			'desc' => TreeIterator::SELF_FIRST,
+			'asc' => TreeIterator::CHILD_FIRST,
+			'leaves' => TreeIterator::LEAVES_ONLY
+		];
+		return new TreeIterator(
+			new NestIterator($this, $nestingKey),
+			isset($modes[$dir]) ? $modes[$dir] : $dir
+		);
+	}
+
 }

+ 76 - 0
src/Collection/Iterator/NestIterator.php

@@ -0,0 +1,76 @@
+<?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         3.0.0
+ * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+namespace Cake\Collection\Iterator;
+
+use Cake\Collection\Collection;
+use RecursiveIterator;
+
+/**
+ * A type of collection that is aware of nested items and exposes methods to
+ * check or retrieve them
+ *
+ */
+class NestIterator extends Collection implements RecursiveIterator {
+
+/**
+ * The name of the property that contains the nested items for each element
+ *
+ * @var string|callable
+ */
+	protected $_nestKey;
+
+/**
+ * Constructor
+ *
+ * @param array|\Traversable $items
+ * @param string|callable $nestKey the property that contains the nested items
+ * If a callable is passed, it should return the childrens for the passed item
+ */
+	public function __construct($items, $nestKey) {
+		parent::__construct($items);
+		$this->_nestKey = $nestKey;
+	}
+
+/**
+ * Returns a traversable containing the children for the current item
+ *
+ * @return \Traversable
+ */
+	public function getChildren() {
+		$property = $this->_propertyExtractor($this->_nestKey);
+		return new self($property($this->current()), $this->_nestKey);
+	}
+
+/**
+ * Returns true if there is an array or a traversable object stored under the
+ * configured nestKey for the current item
+ *
+ * @return boolean
+ */
+	public function hasChildren() {
+		$property = $this->_propertyExtractor($this->_nestKey);
+		$children = $property($this->current());
+
+		if (is_array($children)) {
+			return !empty($children);
+		}
+
+		if ($children instanceof \Traversable) {
+			return true;
+		}
+
+		return false;
+	}
+}

+ 105 - 0
src/Collection/Iterator/TreeIterator.php

@@ -0,0 +1,105 @@
+<?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         3.0.0
+ * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+namespace Cake\Collection\Iterator;
+
+use Cake\Collection\Collection;
+use Cake\Collection\CollectionTrait;
+use Cake\Collection\Iterator\TreePrinter;
+use RecursiveIterator;
+use RecursiveIteratorIterator;
+
+/**
+ * A Recursive iterator used to flatten nested structures and also exposes
+ * all Collection methods
+ *
+ */
+class TreeIterator extends RecursiveIteratorIterator {
+
+	use CollectionTrait;
+
+/**
+ * The iteration mode
+ *
+ * @var integer
+ */
+	protected $_mode;
+
+/**
+ * Constructor
+ *
+ * @param RecursiveIterator $items The iterator to flatten
+ * @param integer $mode
+ * @param integer $flags
+ */
+	public function __construct(RecursiveIterator $items, $mode = RecursiveIteratorIterator::SELF_FIRST, $flags = 0) {
+		parent::__construct($items, $mode, $flags);
+		$this->_mode = $mode;
+	}
+
+/**
+ * Returns another iterator which will return the values ready to be displayed
+ * to a user. It does so by extracting one property from each of the elements
+ * and prefixing it with a spacer so that the relative position in the tree
+ * can be visualized.
+ *
+ * Both $valuePath and $keyPath can be a string with a property name to extract
+ * or a dot separated path of properties that should be followed to get the last
+ * one in the path.
+ *
+ * Alternatively, $valuePath and $keyPath can be callable functions. They will get
+ * the current element as first parameter, the current iteration key as second
+ * parameter, and the iterator instance as third argument.
+ *
+ * ##Example
+ *
+ * {{{
+ *	$printer = (new Collection($treeStructure))->listNested()->printer('name');
+ * }}}
+ *
+ * Using a closure:
+ *
+ * {{{
+ *	$printer = (new Collection($treeStructure))
+ *		->listNested()
+ *		->printer(function($item, $key, $iterator) {
+ *			return $item->name;
+ *		});
+ * }}}
+ *
+ * @param string|callable $valuePath The property to extract or a callable to return
+ * the display value
+ * @param string|callable $keyPath The property to use as iteration key or a
+ * callable returning the key value.
+ * @param string $spacer The string to use for prefixing the values according to
+ * their depth in the tree
+ * @return \Cake\Collection\Iterator\TreePrinter
+ */
+	public function printer($valuePath, $keyPath = null, $spacer = '__') {
+		if (!$keyPath) {
+			$counter = 0;
+			$keyPath = function() use (&$counter) {
+				return $counter++;
+			};
+		}
+		return new TreePrinter(
+			$this->getInnerIterator(),
+			$valuePath,
+			$keyPath,
+			$spacer,
+			$this->_mode
+		);
+	}
+
+}

+ 115 - 0
src/Collection/Iterator/TreePrinter.php

@@ -0,0 +1,115 @@
+<?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         3.0.0
+ * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+namespace Cake\Collection\Iterator;
+
+use Cake\Collection\Collection;
+use Cake\Collection\CollectionTrait;
+use Cake\Collection\Iterator\TreePrinter;
+use RecursiveIteratorIterator;
+
+/**
+ * Iterator for flattening elements in a tree structure while adding some
+ * visual markers for their relative position in the tree
+ *
+ */
+class TreePrinter extends RecursiveIteratorIterator {
+
+	use CollectionTrait;
+
+/**
+ * A callable to generate the iteration key
+ *
+ * @var callable
+ */
+	protected $_key;
+
+/**
+ * A callable to extract the display value
+ *
+ * @var callable
+ */
+	protected $_value;
+
+/**
+ * Cached value for the current iteration element
+ *
+ * @var mixed
+ */
+	protected $_current;
+
+/**
+ * Constructor
+ *
+ * @param RecursiveIterator $items The iterator to flatten
+ * @param string|callable $valuePath The property to extract or a callable to return
+ * the display value
+ * @param string|callable $keyPath The property to use as iteration key or a
+ * callable returning the key value.
+ * @param string $spacer The string to use for prefixing the values according to
+ * their depth in the tree
+ * @param integer $mode
+ */
+	public function __construct($items, $valuePath, $keyPath, $spacer, $mode = RecursiveIteratorIterator::SELF_FIRST) {
+		parent::__construct($items, $mode);
+		$this->_value = $this->_propertyExtractor($valuePath);
+		$this->_key = $this->_propertyExtractor($keyPath);
+		$this->_spacer = $spacer;
+	}
+
+/**
+ * Returns the current iteration key
+ *
+ * @return mixed
+ */
+	public function key() {
+		$extractor = $this->_key;
+		return $extractor($this->_fetchCurrent(), parent::key(), $this);
+	}
+
+/**
+ * Returns the current iteration value
+ *
+ * @return string
+ */
+	public function current() {
+		$extractor = $this->_value;
+		$current = $this->_fetchCurrent();
+		$spacer = str_repeat($this->_spacer, $this->getDepth());
+		return $spacer . $extractor($current, parent::key(), $this);
+	}
+
+/**
+ * Advances the cursor one position
+ *
+ * @return void
+ */
+	public function next() {
+		parent::next();
+		$this->_current = null;
+	}
+
+/**
+ * Returns the current iteration element and caches its value
+ *
+ * @return mixed
+ */
+	protected function _fetchCurrent() {
+		if ($this->_current !== null) {
+			return $this->_current;
+		}
+		return $this->_current = parent::current();
+	}
+
+}

+ 66 - 0
tests/TestCase/Collection/CollectionTest.php

@@ -872,4 +872,70 @@ class CollectionTest extends TestCase {
 		);
 	}
 
+/**
+ * Provider for testing each of the direcations for listNested
+ *
+ * @return void
+ */
+	public function nestedListProvider() {
+		return [
+			['desc', [1, 2, 3, 5, 7, 4, 8, 6, 9, 10]],
+			['asc', [5, 7, 3, 8, 4, 2, 1, 9, 10, 6]],
+			['leaves', [5, 7, 8, 9, 10]]
+		];
+	}
+
+/**
+ * Tests the listNested method with the default 'children' nesting key
+ *
+ * @dataProvider nestedListProvider
+ * @return void
+ */
+	public function testListNested($dir, $expected) {
+		$items = [
+			['id' => 1, 'parent_id' => null],
+			['id' => 2, 'parent_id' => 1],
+			['id' => 3, 'parent_id' => 2],
+			['id' => 4, 'parent_id' => 2],
+			['id' => 5, 'parent_id' => 3],
+			['id' => 6, 'parent_id' => null],
+			['id' => 7, 'parent_id' => 3],
+			['id' => 8, 'parent_id' => 4],
+			['id' => 9, 'parent_id' => 6],
+			['id' => 10, 'parent_id' => 6]
+		];
+		$collection = (new Collection($items))->nest('id', 'parent_id')->listNested($dir);
+		$this->assertEquals($expected, $collection->extract('id')->toArray(false));
+	}
+
+/**
+ * Tests using listNested with a different nesting key
+ *
+ * @return void
+ */
+	public function testListNestedCustomKey() {
+		$items = [
+			['id' => 1, 'stuff' => [['id' => 2, 'stuff' => [['id' => 3]]]]],
+			['id' => 4, 'stuff' => [['id' => 5]]]
+		];
+		$collection = (new Collection($items))->listNested('desc', 'stuff');
+		$this->assertEquals(range(1, 5), $collection->extract('id')->toArray(false));
+	}
+
+/**
+ * Tests flattening the collection using a custom callable function
+ *
+ * @return void
+ */
+	public function testListNestedWithCallable() {
+		$items = [
+			['id' => 1, 'stuff' => [['id' => 2, 'stuff' => [['id' => 3]]]]],
+			['id' => 4, 'stuff' => [['id' => 5]]]
+		];
+		$collection = (new Collection($items))->listNested('desc', function($item) {
+			return isset($item['stuff']) ? $item['stuff'] : [];
+		});
+		$this->assertEquals(range(1, 5), $collection->extract('id')->toArray(false));
+	}
+
 }

+ 115 - 0
tests/TestCase/Collection/Iterator/TreeIteratorTest.php

@@ -0,0 +1,115 @@
+<?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         3.0.0
+ * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
+ */
+namespace Cake\Test\TestCase\Collection\Iterator;
+
+use Cake\Collection\Iterator\NestIterator;
+use Cake\Collection\Iterator\TreeIterator;
+use Cake\TestSuite\TestCase;
+
+/**
+ * TreeIterator Test
+ *
+ */
+class TreeIteratorTest extends TestCase {
+
+/**
+ * Tests the printer function with defaults
+ *
+ * @return void
+ */
+	public function testPrinter() {
+		$items = [
+			[
+				'id' => 1,
+				'name' => 'a',
+				'stuff' => [
+					['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]]
+				]
+			],
+			['id' => 4, 'name' => 'd', 'stuff' => [['id' => 5, 'name' => 'e']]]
+		];
+		$items = new NestIterator($items, 'stuff');
+		$result = (new TreeIterator($items))->printer('name')->toArray();
+		$expected = [
+			'a',
+			'__b',
+			'____c',
+			'd',
+			'__e'
+		];
+		$this->assertEquals($expected, $result);
+	}
+
+/**
+ * Tests the printer function with a custom key extractor and spacer
+ *
+ * @return void
+ */
+	public function testPrinterCustomKeyAndSpacer() {
+			$items = [
+			[
+				'id' => 1,
+				'name' => 'a',
+				'stuff' => [
+					['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]]
+				]
+			],
+			['id' => 4, 'name' => 'd', 'stuff' => [['id' => 5, 'name' => 'e']]]
+		];
+		$items = new NestIterator($items, 'stuff');
+		$result = (new TreeIterator($items))->printer('id', 'name', '@@')->toArray();
+		$expected = [
+			'a' => '1',
+			'b' => '@@2',
+			'c' => '@@@@3',
+			'd' => '4',
+			'e' => '@@5'
+		];
+		$this->assertEquals($expected, $result);
+	}
+
+/**
+ * Tests the printer function with a closure extractor
+ *
+ * @return void
+ */
+	public function testPrinterWithClosure() {
+		$items = [
+			[
+				'id' => 1,
+				'name' => 'a',
+				'stuff' => [
+					['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]]
+				]
+			],
+			['id' => 4, 'name' => 'd', 'stuff' => [['id' => 5, 'name' => 'e']]]
+		];
+		$items = new NestIterator($items, 'stuff');
+		$result = (new TreeIterator($items))
+			->printer(function($element, $key, $iterator) {
+				return ($iterator->getDepth() + 1 ) . '.' . $key . ' ' . $element['name'];
+			}, null, null)
+			->toArray();
+		$expected = [
+			'1.0 a',
+			'2.0 b',
+			'3.0 c',
+			'1.1 d',
+			'2.0 e'
+		];
+		$this->assertEquals($expected, $result);
+	}
+
+}