Browse Source

Merge pull request #5445 from cakephp/3.0-collection-unfold

3.0 collection unfold
Mark Story 11 years ago
parent
commit
06eca52a64

+ 70 - 0
src/Collection/CollectionInterface.php

@@ -730,4 +730,74 @@ interface CollectionInterface extends Iterator, JsonSerializable {
  */
 	public function listNested($dir = 'desc', $nestingKey = 'children');
 
+/**
+ * Creates a new collection that when iterated will stop yielding results if
+ * the provided condition evaluates to false.
+ *
+ * This is handy for dealing with infinite iterators or any generator that
+ * could start returning invalid elements at a certain point. For example,
+ * when reading lines from a file stream you may want to stop the iteration
+ * after a certain value is reached.
+ *
+ * ### Example:
+ *
+ * Get an array of lines in a CSV file until the timestamp column is less than a date
+ *
+ * {{{
+ * $lines = (new Collection($fileLines))->stopWhen(function ($value, $key) {
+ *  return (new DateTime($value))->format('Y') < 2012;
+ * })
+ * ->toArray();
+ * }}}
+ *
+ * Get elements until the first unapproved message is found:
+ *
+ * {{{
+ * $comments = (new Collection($comments))->stopWhen(['is_approved' => false]);
+ * }}}
+ *
+ * @param callable|array $condition the method that will receive each of the elements and
+ * returns false when the iteration should be stopped.
+ * If an array, it will be interpreted as a key-value list of conditions where
+ * the key is a property path as accepted by `Collection::extract`,
+ * and the value the condition against with each element will be matched.
+ * @return \Cake\Collection\CollectionInterface
+ */
+	public function stopWhen($condition);
+
+/**
+ * Creates a new collection where the items that it will contain are the
+ * concatenation of the lists of items generated by the transformer function
+ * after passing each of the items form the original collection.
+ *
+ * The transformer function will receive the value and the key for each of the
+ * items in the collection, in that order, and it must return an array or a
+ * Traversable object so that it can be concatenated to the final result.
+ *
+ * If no transformer function is passed, an "identity" function will be used.
+ * This is useful when each of the elements in the source collection are
+ * lists of items to be appended one after another.
+ *
+ * ### Example:
+ *
+ * {{{
+ * $items [[1, 2, 3], [4, 5]];
+ * $unfold = (new Collection($items))->unfold(); // Returns [1, 2, 3, 4, 5]
+ * }}}
+ *
+ * Using a transformer
+ *
+ * {{{
+ * $items [1, 2, 3];
+ * $allItems = (new Collection($items))->unfold(function ($page) {
+ *	return $service->fetchPage($page)->toArray();
+ * });
+ * }}}
+ *
+ * @param callable|array $transformer A callable function that will receive each of
+ * the items in the collection and should return an array or Traversable object
+ * @return \Cake\Collection\CollectionInterface
+ */
+	public function unfold(callable $transformer = null);
+
 }

+ 33 - 16
src/Collection/CollectionTrait.php

@@ -25,8 +25,11 @@ use Cake\Collection\Iterator\MapReduce;
 use Cake\Collection\Iterator\NestIterator;
 use Cake\Collection\Iterator\ReplaceIterator;
 use Cake\Collection\Iterator\SortIterator;
+use Cake\Collection\Iterator\StoppableIterator;
 use Cake\Collection\Iterator\TreeIterator;
+use Cake\Collection\Iterator\UnfoldIterator;
 use LimitIterator;
+use RecursiveIteratorIterator;
 
 /**
  * Offers a handful of method to manipulate iterators
@@ -254,22 +257,7 @@ trait CollectionTrait {
  *
  */
 	public function match(array $conditions) {
-		$matchers = [];
-		foreach ($conditions as $property => $value) {
-			$extractor = $this->_propertyExtractor($property);
-			$matchers[] = function ($v) use ($extractor, $value) {
-				return $extractor($v) == $value;
-			};
-		}
-
-		$filter = function ($value) use ($matchers) {
-			$valid = true;
-			foreach ($matchers as $match) {
-				$valid = $valid && $match($value);
-			}
-			return $valid;
-		};
-		return $this->filter($filter);
+		return $this->filter($this->_createMatcherFilter($conditions));
 	}
 
 /**
@@ -441,4 +429,33 @@ trait CollectionTrait {
 		);
 	}
 
+/**
+ * {@inheritDoc}
+ *
+ * @return \Cake\Collection\Iterator\StoppableIterator
+ */
+	public function stopWhen($condition) {
+		if (!is_callable($condition)) {
+			$condition = $this->_createMatcherFilter($condition);
+		}
+		return new StoppableIterator($this, $condition);
+	}
+
+/**
+ * {@inheritDoc}
+ *
+ */
+	public function unfold(callable $transformer = null) {
+		if ($transformer === null) {
+			$transformer = function ($item) {
+				return $item;
+			};
+		}
+
+		return new Collection(
+			new RecursiveIteratorIterator(
+				new UnfoldIterator($this, $transformer), RecursiveIteratorIterator::LEAVES_ONLY
+			)
+		);
+	}
 }

+ 28 - 0
src/Collection/ExtractTrait.php

@@ -60,4 +60,32 @@ trait ExtractTrait {
 		return $value;
 	}
 
+/**
+ * Returns a callable that receives a value and will return whether or not
+ * it matches certain condition.
+ *
+ * @param array $conditions A key-value list of conditions to match where the
+ * key is the property path to get from the current item and the value is the
+ * value to be compared the item with.
+ * @return callable
+ */
+	protected function _createMatcherFilter(array $conditions) {
+		$matchers = [];
+		foreach ($conditions as $property => $value) {
+			$extractor = $this->_propertyExtractor($property);
+			$matchers[] = function ($v) use ($extractor, $value) {
+				return $extractor($v) == $value;
+			};
+		}
+
+		return function ($value) use ($matchers) {
+			foreach ($matchers as $match) {
+				if (!$match($value)) {
+					return false;
+				}
+			}
+			return true;
+		};
+	}
+
 }

+ 44 - 0
src/Collection/Iterator/NoChildrenIterator.php

@@ -0,0 +1,44 @@
+<?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       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Collection\Iterator;
+
+use Cake\Collection\Collection;
+use RecursiveIterator;
+
+/**
+ * An iterator that can be used as argument for other iterators that require
+ * a RecursiveIterator, but that will always report as having no nested items.
+ */
+class NoChildrenIterator extends Collection implements RecursiveIterator {
+
+/**
+ * Returns false as there are no children iterators in this collection
+ *
+ * @return bool
+ */
+	public function hasChildren() {
+		return false;
+	}
+
+/**
+ * Returns null as there are no children for this iteration level
+ *
+ * @return null
+ */
+	public function getChildren() {
+		return null;
+	}
+
+}

+ 70 - 0
src/Collection/Iterator/StoppableIterator.php

@@ -0,0 +1,70 @@
+<?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       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Collection\Iterator;
+
+use Cake\Collection\Collection;
+
+/**
+ * Creates an iterator from another iterator that will verify a condition on each
+ * step. If the condition evaluates to false, the iterator will not yield more
+ * results.
+ *
+ * @internal
+ * @see Collection::stopWhen()
+ */
+class StoppableIterator extends Collection {
+
+/**
+ * The condition to evaluate for each item of the collection
+ *
+ * @var callable
+ */
+	protected $_condition;
+
+/**
+ * Creates an iterator that can be stopped based on a condition provided by a callback.
+ *
+ * Each time the condition callback is executed it will receive the value of the element
+ * in the current iteration, the key of the element and the passed $items iterator
+ * as arguments, in that order.
+ *
+ * @param array|\Traversable $items The list of values to iterate
+ * @param callable $condition A function that will be called for each item in
+ * the collection, if the result evaluates to false, no more items will be
+ * yielded from this iterator.
+ */
+	public function __construct($items, callable $condition) {
+		$this->_condition = $condition;
+		parent::__construct($items);
+	}
+
+/**
+ * Evaluates the condition and returns its result, this controls
+ * whether or not more results will be yielded.
+ *
+ * @return bool
+ */
+	public function valid() {
+		if (!parent::valid()) {
+			return false;
+		}
+
+		$current = $this->current();
+		$key = $this->key();
+		$condition = $this->_condition;
+		return !$condition($current, $key, $this);
+	}
+
+}

+ 76 - 0
src/Collection/Iterator/UnfoldIterator.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       http://www.opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Collection\Iterator;
+
+use Cake\Collection\Iterator\NoChildrenIterator;
+use IteratorIterator;
+use RecursiveIterator;
+
+/**
+ * An iterator that can be used to generate nested iterators out of each of
+ * applying an function to each of the elements in this iterator.
+ *
+ * @internal
+ * @see Collection::unfold()
+ */
+class UnfoldIterator extends IteratorIterator implements RecursiveIterator {
+
+/**
+ * A functions that gets passed each of the elements of this iterator and
+ * that must return an array or Traversable object.
+ *
+ * @var callable
+ */
+	protected $_unfolder;
+
+/**
+ * Creates the iterator that will generate child iterators from each of the
+ * elements it was constructed with.
+ *
+ * @param array|\Traversable $items The list of values to iterate
+ * @param callable $unfolder A callable function that will receive the
+ * current item and key. It must return an array or Traversable object
+ * out of which the nested iterators will be yielded.
+ */
+	public function __construct($items, callable $unfolder) {
+		$this->_unfolder = $unfolder;
+		parent::__construct($items);
+	}
+
+/**
+ * Returns true as each of the elements in the array represent a
+ * list of items
+ *
+ * @return bool
+ */
+	public function hasChildren() {
+		return true;
+	}
+
+/**
+ * Returns an iterator containing the items generated out of transforming
+ * the current value with the callable function.
+ *
+ * @return \RecursiveIterator
+ */
+	public function getChildren() {
+		$current = $this->current();
+		$key = $this->key();
+		$unfolder = $this->_unfolder;
+
+		return new NoChildrenIterator($unfolder($current, $key, $this));
+	}
+
+}

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

@@ -967,4 +967,84 @@ class CollectionTest extends TestCase {
 		$this->assertEquals(600, $sum);
 	}
 
+/**
+ * Tests the stopWhen method with a callable
+ *
+ * @return void
+ */
+	public function testStopWhenCallable() {
+		$items = [10, 20, 40, 10, 5];
+		$collection = (new Collection($items))->stopWhen(function ($v) {
+			return $v > 20;
+		});
+		$this->assertEquals([10, 20], $collection->toArray());
+	}
+
+/**
+ * Tests the stopWhen method with a matching array
+ *
+ * @return void
+ */
+	public function testStopWhenWithArray() {
+		$items = [
+			['foo' => 'bar'],
+			['foo' => 'baz'],
+			['foo' => 'foo']
+		];
+		$collection = (new Collection($items))->stopWhen(['foo' => 'baz']);
+		$this->assertEquals([['foo' => 'bar']], $collection->toArray());
+	}
+
+/**
+ * Tests the unfold method
+ *
+ * @return void
+ */
+	public function testUnfold() {
+		$items = [
+			[1, 2, 3, 4],
+			[5, 6],
+			[7, 8]
+		];
+
+		$collection = (new Collection($items))->unfold();
+		$this->assertEquals(range(1, 8), $collection->toArray(false));
+
+		$items = [
+			[1, 2],
+			new Collection([3, 4])
+		];
+		$collection = (new Collection($items))->unfold();
+		$this->assertEquals(range(1, 4), $collection->toArray(false));
+	}
+
+/**
+ * Tests the unfold method with empty levels
+ *
+ * @return void
+ */
+	public function testUnfoldEmptyLevels() {
+		$items = [[], [1, 2], []];
+		$collection = (new Collection($items))->unfold();
+		$this->assertEquals(range(1, 2), $collection->toArray(false));
+
+		$items = [];
+		$collection = (new Collection($items))->unfold();
+		$this->assertEmpty($collection->toArray(false));
+	}
+
+/**
+ * Tests the unfold when passing a callable
+ *
+ * @return void
+ */
+	public function testUnfoldWithCallable() {
+		$items = [1, 2, 3];
+		$collection = (new Collection($items))->unfold(function ($item) {
+			return range($item, $item * 2);
+		});
+		$expected = [1, 2, 2, 3, 4, 3, 4, 5, 6];
+		$this->assertEquals($expected, $collection->toArray(false));
+	}
+
 }