-
Notifications
You must be signed in to change notification settings - Fork 266
PHPLIB-81: Implement CachingIterator #424
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9f67730
0d5b7c4
b2a76bf
5ea6f35
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,7 +10,7 @@ | |
{ "name": "Derick Rethans", "email": "[email protected]" } | ||
], | ||
"require": { | ||
"php": ">=5.4", | ||
"php": ">=5.5", | ||
"ext-hash": "*", | ||
"ext-json": "*", | ||
"ext-mongodb": "^1.3.0" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,166 @@ | ||
<?php | ||
/* | ||
* Copyright 2017 MongoDB, Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
namespace MongoDB\Model; | ||
|
||
use Countable; | ||
use Generator; | ||
use Iterator; | ||
use Traversable; | ||
|
||
/** | ||
* Iterator for wrapping a Traversable and caching its results. | ||
* | ||
* By caching results, this iterators allows a Traversable to be counted and | ||
* rewound multiple times, even if the wrapped object does not natively support | ||
* those operations (e.g. MongoDB\Driver\Cursor). | ||
* | ||
* @internal | ||
*/ | ||
class CachingIterator implements Countable, Iterator | ||
{ | ||
private $items = []; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This helps if we count an empty Traversable, as the early return in |
||
private $iterator; | ||
private $iteratorAdvanced = false; | ||
private $iteratorExhausted = false; | ||
|
||
/** | ||
* Constructor. | ||
* | ||
* Initialize the iterator and stores the first item in the cache. This | ||
* effectively rewinds the Traversable and the wrapping Generator, which | ||
* will execute up to its first yield statement. Additionally, this mimics | ||
* behavior of the SPL iterators and allows users to omit an explicit call | ||
* to rewind() before using the other methods. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @alcaeus: Strictly speaking, Iterators must call |
||
* | ||
* @param Traversable $traversable | ||
*/ | ||
public function __construct(Traversable $traversable) | ||
{ | ||
$this->iterator = $this->wrapTraversable($traversable); | ||
$this->storeCurrentItem(); | ||
} | ||
|
||
/** | ||
* @see http://php.net/countable.count | ||
* @return integer | ||
*/ | ||
public function count() | ||
{ | ||
$this->exhaustIterator(); | ||
|
||
return count($this->items); | ||
} | ||
|
||
/** | ||
* @see http://php.net/iterator.current | ||
* @return mixed | ||
*/ | ||
public function current() | ||
{ | ||
return current($this->items); | ||
} | ||
|
||
/** | ||
* @see http://php.net/iterator.mixed | ||
* @return mixed | ||
*/ | ||
public function key() | ||
{ | ||
return key($this->items); | ||
} | ||
|
||
/** | ||
* @see http://php.net/iterator.next | ||
* @return void | ||
*/ | ||
public function next() | ||
{ | ||
if ( ! $this->iteratorExhausted) { | ||
$this->iterator->next(); | ||
$this->storeCurrentItem(); | ||
} | ||
|
||
next($this->items); | ||
} | ||
|
||
/** | ||
* @see http://php.net/iterator.rewind | ||
* @return void | ||
*/ | ||
public function rewind() | ||
{ | ||
/* If the iterator has advanced, exhaust it now so that future iteration | ||
* can rely on the cache. | ||
*/ | ||
if ($this->iteratorAdvanced) { | ||
$this->exhaustIterator(); | ||
} | ||
|
||
reset($this->items); | ||
} | ||
|
||
/** | ||
* | ||
* @see http://php.net/iterator.valid | ||
* @return boolean | ||
*/ | ||
public function valid() | ||
{ | ||
return $this->key() !== null; | ||
} | ||
|
||
/** | ||
* Ensures that the inner iterator is fully consumed and cached. | ||
*/ | ||
private function exhaustIterator() | ||
{ | ||
while ( ! $this->iteratorExhausted) { | ||
$this->next(); | ||
} | ||
} | ||
|
||
/** | ||
* Stores the current item in the cache. | ||
*/ | ||
private function storeCurrentItem() | ||
{ | ||
$key = $this->iterator->key(); | ||
|
||
if ($key === null) { | ||
return; | ||
} | ||
|
||
$this->items[$key] = $this->iterator->current(); | ||
} | ||
|
||
/** | ||
* Wraps the Traversable with a Generator. | ||
* | ||
* @param Traversable $traversable | ||
* @return Generator | ||
*/ | ||
private function wrapTraversable(Traversable $traversable) | ||
{ | ||
foreach ($traversable as $key => $value) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to recap, each |
||
yield $key => $value; | ||
$this->iteratorAdvanced = true; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My understanding (and observation) is that the first call to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct - this could be tested by adding an assertion on the
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't realize those assertions existed :) I think I'll do without them here, since we can get by testing with just the public API. Will keep it in mind for future use, though! |
||
} | ||
|
||
$this->iteratorExhausted = true; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
<?php | ||
|
||
namespace MongoDB\Tests\Model; | ||
|
||
use MongoDB\Model\CachingIterator; | ||
use Exception; | ||
|
||
class CachingIteratorTest extends \PHPUnit_Framework_TestCase | ||
{ | ||
/** | ||
* Sanity check for all following tests. | ||
* | ||
* @expectedException \Exception | ||
* @expectedExceptionMessage Cannot traverse an already closed generator | ||
*/ | ||
public function testTraversingGeneratorConsumesIt() | ||
{ | ||
$iterator = $this->getTraversable([1, 2, 3]); | ||
$this->assertSame([1, 2, 3], iterator_to_array($iterator)); | ||
$this->assertSame([1, 2, 3], iterator_to_array($iterator)); | ||
} | ||
|
||
public function testConstructorRewinds() | ||
{ | ||
$iterator = new CachingIterator($this->getTraversable([1, 2, 3])); | ||
|
||
$this->assertTrue($iterator->valid()); | ||
$this->assertSame(0, $iterator->key()); | ||
$this->assertSame(1, $iterator->current()); | ||
} | ||
|
||
public function testIteration() | ||
{ | ||
$iterator = new CachingIterator($this->getTraversable([1, 2, 3])); | ||
|
||
$expectedKey = 0; | ||
$expectedItem = 1; | ||
|
||
foreach ($iterator as $key => $item) { | ||
$this->assertSame($expectedKey++, $key); | ||
$this->assertSame($expectedItem++, $item); | ||
} | ||
|
||
$this->assertFalse($iterator->valid()); | ||
} | ||
|
||
public function testIterationWithEmptySet() | ||
{ | ||
$iterator = new CachingIterator($this->getTraversable([])); | ||
|
||
$iterator->rewind(); | ||
$this->assertFalse($iterator->valid()); | ||
} | ||
|
||
public function testPartialIterationDoesNotExhaust() | ||
{ | ||
$traversable = $this->getTraversableThatThrows([1, 2, new Exception]); | ||
$iterator = new CachingIterator($traversable); | ||
|
||
$expectedKey = 0; | ||
$expectedItem = 1; | ||
|
||
foreach ($iterator as $key => $item) { | ||
$this->assertSame($expectedKey++, $key); | ||
$this->assertSame($expectedItem++, $item); | ||
|
||
if ($key === 1) { | ||
break; | ||
} | ||
} | ||
|
||
$this->assertTrue($iterator->valid()); | ||
} | ||
|
||
public function testRewindAfterPartialIteration() | ||
{ | ||
$iterator = new CachingIterator($this->getTraversable([1, 2, 3])); | ||
|
||
$iterator->rewind(); | ||
$this->assertTrue($iterator->valid()); | ||
$this->assertSame(0, $iterator->key()); | ||
$this->assertSame(1, $iterator->current()); | ||
|
||
$iterator->next(); | ||
$this->assertSame([1, 2, 3], iterator_to_array($iterator)); | ||
} | ||
|
||
public function testCount() | ||
{ | ||
$iterator = new CachingIterator($this->getTraversable([1, 2, 3])); | ||
$this->assertCount(3, $iterator); | ||
} | ||
|
||
public function testCountAfterPartialIteration() | ||
{ | ||
$iterator = new CachingIterator($this->getTraversable([1, 2, 3])); | ||
|
||
$iterator->rewind(); | ||
$this->assertTrue($iterator->valid()); | ||
$this->assertSame(0, $iterator->key()); | ||
$this->assertSame(1, $iterator->current()); | ||
|
||
$iterator->next(); | ||
$this->assertCount(3, $iterator); | ||
} | ||
|
||
public function testCountWithEmptySet() | ||
{ | ||
$iterator = new CachingIterator($this->getTraversable([])); | ||
$this->assertCount(0, $iterator); | ||
} | ||
|
||
private function getTraversable($items) | ||
{ | ||
foreach ($items as $item) { | ||
yield $item; | ||
} | ||
} | ||
|
||
private function getTraversableThatThrows($items) | ||
{ | ||
foreach ($items as $item) { | ||
if ($item instanceof Exception) { | ||
throw $item; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The stack trace of the exception only reports when it was constructed (it's not generated/updated when the exception is thrown). I was surprised to learn that, but it's not too important as we only ever assert that nothing is thrown (testing that initial rewind no longer exhausts the internal traversable). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe I tried fixing that in Xdebug, but adding the previous exception to a special property. |
||
} else { | ||
yield $item; | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do this if if you're not aliasing them, or plucking them out of another namespace? And if you want the root namespace, shouldn't it be
use \Countable
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use
statements use fully qualified names, so the leading backslash isn't necessary. Out of habit, I list all used classes at the top of the file, rather than making a special case of\Countable
for classes in the global namespace.