diff --git a/Cake/Test/TestCase/Utility/CollectionTest.php b/Cake/Test/TestCase/Utility/CollectionTest.php index 98592ac0dbd..f99096c3856 100644 --- a/Cake/Test/TestCase/Utility/CollectionTest.php +++ b/Cake/Test/TestCase/Utility/CollectionTest.php @@ -312,4 +312,61 @@ public function testMin() { $this->assertEquals(['a' => ['b' => ['c' => 4]]], $collection->min('a.b.c')); } +/** + * Tests groupBy + * + * @return void + */ + public function testGroupBy() { + $items = [ + ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + ]; + $collection = new Collection($items); + $grouped = $collection->groupBy('parent_id'); + $expected = [ + 10 => [ + ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + ], + 11 => [ + ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + ] + ]; + $this->assertEquals($expected, iterator_to_array($grouped)); + $this->assertInstanceOf('\Cake\Utility\Collection', $grouped); + + $grouped = $collection->groupBy(function($element) { + return $element['parent_id']; + }); + $this->assertEquals($expected, iterator_to_array($grouped)); + } + +/** + * Tests grouping by a deep key + * + * @return void + */ + public function testGroupByDeepKey() { + $items = [ + ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], + ['id' => 2, 'name' => 'bar', 'thing' => ['parent_id' => 11]], + ['id' => 3, 'name' => 'baz', 'thing' => ['parent_id' => 10]], + ]; + $collection = new Collection($items); + $grouped = $collection->groupBy('thing.parent_id'); + $expected = [ + 10 => [ + ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], + ['id' => 3, 'name' => 'baz', 'thing' => ['parent_id' => 10]], + ], + 11 => [ + ['id' => 2, 'name' => 'bar', 'thing' => ['parent_id' => 11]], + ] + ]; + $this->assertEquals($expected, iterator_to_array($grouped)); + } + + } diff --git a/Cake/Utility/Collection.php b/Cake/Utility/Collection.php index 5ecb74fa8c0..a6ab0b8bcac 100644 --- a/Cake/Utility/Collection.php +++ b/Cake/Utility/Collection.php @@ -370,7 +370,54 @@ public function sortBy($callback, $dir = SORT_DESC, $type = SORT_NUMERIC) { return new self(new SortIterator($this, $callback, $dir, $type)); } - public function groupBy($property) { +/** + * Splits a collection into sets, grouped by the result of running each value + * through the callback. If $callback is is a string instead of a callable, + * groups by the property named by $callback on each of the values. + * + * When $callback is a string it should be a property name to extract or + * a dot separated path of properties that should be followed to get the last + * one in the path. + * + * ###Example: + * + * {{{ + * $items = [ + * ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + * ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + * ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + * ]; + * + * $group = (new Collection($items))->groupBy('parent_id'); + * + * //Or + * $group = (new Collection($items))->groupBy(function($e) { + * return $e['parent_id']; + * }); + * + * //Result will look like + * [ + * 10 => [ + * ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + * ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + * ], + * 11 => [ + * ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + * ] + * ]; + * }}} + * + * @param callable|string the callback or column name to use for grouping + * or a function returning the grouping key out of the provided element + * @return \Cake\Utility\Collection + */ + public function groupBy($callback) { + $callback = $this->_propertyExtractor($callback); + $group = []; + foreach ($this as $value) { + $group[$callback($value)][] = $value; + } + return new self($group); } public function indexBy($property) { @@ -385,4 +432,44 @@ public function shuffle() { public function sample($size) { } +/** + * Returns a callable that can be used to extract a property or column from + * an array or object based on a dot separated path. + * + * @param string|callable $callback A dot separated path of column to follow + * so that the final one can be returned or a callable that will take care + * of doing that. + * @return callable + */ + protected function _propertyExtractor($callback) { + if (is_string($callback)) { + $path = $path = explode('.', $callback); + $callback = function($element) use ($path) { + return $this->_extract($element, $path); + }; + } + + return $callback; + } + +/** + * Returns a column from $data that can be extracted + * by iterating over the column names contained in $path + * + * @param array|\ArrayAccess $data + * @param array $path + * @return mixed + */ + protected function _extract($data, $path) { + $value = null; + foreach ($path as $column) { + if (!isset($data[$column])) { + return null; + } + $value = $data[$column]; + $data = $value; + } + return $value; + } + } diff --git a/Cake/Utility/Iterator/ExtractIterator.php b/Cake/Utility/Iterator/ExtractIterator.php index 430072d92e4..ef2ff3c25b6 100644 --- a/Cake/Utility/Iterator/ExtractIterator.php +++ b/Cake/Utility/Iterator/ExtractIterator.php @@ -65,15 +65,7 @@ public function __construct($items, $path) { */ public function current() { $current = parent::current(); - $value = null; - foreach ($this->_path as $column) { - if (!isset($current[$column])) { - return null; - } - $value = $current[$column]; - $current = $value; - } - return $value; + return $this->_extract($current, $this->_path); } }