Implement PHP iterator for lists based on the LRANGE command.

This iterator tries to mimic the same behaviour of the cursor-based
iterators implemented upon the SCAN family of commands and offering
only limited guarantees on the returned elements, but uses LRANGE to
fetch items from a list incrementally.
This commit is contained in:
Daniele Alessandri
2013-11-07 14:54:47 +01:00
parent 66f98186f0
commit 9b8b362747
3 changed files with 430 additions and 1 deletions
+7 -1
View File
@@ -6,7 +6,13 @@ v0.8.5 (2013-xx-xx)
- Added `SCAN`, `SSCAN`, `ZSCAN`, `HSCAN` to the server profile for Redis 2.8.
- Implemented iterator-based abstraction for `SCAN`, `SSCAN`, `ZSCAN`, `HSCAN`.
- Implemented PHP iterators for incremental iterations over Redis collections:
- keyspace (cursor-based iterator using `SCAN`)
- sets (cursor-based iterator using `SSCAN`)
- sorted sets (cursor-based iterator using `ZSCAN`)
- hashes (cursor-based iterator using `HSCAN`)
- lists (plain iterator using `LRANGE`)
- `Predis\Client::pubSubLoop()` should now be used instead of the deprecated
`Predis\Client::pubSub()` (which still works like usual to avoid B/C breaks).
+174
View File
@@ -0,0 +1,174 @@
<?php
/*
* This file is part of the Predis package.
*
* (c) Daniele Alessandri <suppakilla@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Predis\Collection\Iterator;
use InvalidArgumentException;
use Iterator;
use Predis\ClientInterface;
use Predis\NotSupportedException;
/**
* Abstracts the iteration of items stored in a list by leveraging the LRANGE
* command wrapped in a fully-rewindable PHP iterator.
*
* This iterator tries to emulate the behaviour of cursor-based iterators based
* on the SCAN-family of commands introduced in Redis <= 2.8, meaning that due
* to its incremental nature with multiple fetches it can only offer limited
* guarantees on the returned elements because the collection can change several
* times (trimmed, deleted, overwritten) during the iteration process.
*
* @author Daniele Alessandri <suppakilla@gmail.com>
* @link http://redis.io/commands/lrange
*/
class ListKey implements Iterator
{
protected $client;
protected $count;
protected $key;
protected $valid;
protected $fetchmore;
protected $elements;
protected $position;
protected $current;
/**
* @param ClientInterface $client Client connected to Redis.
* @param string $key Redis list key.
* @param int $count Number of items retrieved on each fetch operation.
*/
public function __construct(ClientInterface $client, $key, $count = 10)
{
$this->requiredCommand($client, 'LRANGE');
if ((false === $count = filter_var($count, FILTER_VALIDATE_INT)) || $count < 0) {
throw new InvalidArgumentException('The $count argument must be a positive integer.');
}
$this->client = $client;
$this->key = $key;
$this->count = $count;
$this->reset();
}
/**
* Ensures that the client instance supports the specified Redis
* command required to fetch elements from the server to perform
* the iteration.
*
* @param ClientInterface Client connected to Redis.
* @param string $commandID Command ID.
*/
protected function requiredCommand(ClientInterface $client, $commandID)
{
if (!$client->getProfile()->supportsCommand($commandID)) {
throw new NotSupportedException("The specified server profile does not support the `$commandID` command.");
}
}
/**
* Resets the inner state of the iterator.
*/
protected function reset()
{
$this->valid = true;
$this->fetchmore = true;
$this->elements = array();
$this->position = -1;
$this->current = null;
}
/**
* Fetches a new set of elements from the remote collection,
* effectively advancing the iteration process.
*
* @return array
*/
protected function executeCommand()
{
return $this->client->lrange($this->key, $this->position + 1, $this->position + $this->count);
}
/**
* Populates the local buffer of elements fetched from the
* server during the iteration.
*/
protected function fetch()
{
$elements = $this->executeCommand();
if (count($elements) < $this->count) {
$this->fetchmore = false;
}
$this->elements = $elements;
}
/**
* Extracts next values for key() and current().
*/
protected function extractNext()
{
$this->position++;
$this->current = array_shift($this->elements);
}
/**
* {@inheritdoc}
*/
public function rewind()
{
$this->reset();
$this->next();
}
/**
* {@inheritdoc}
*/
public function current()
{
return $this->current;
}
/**
* {@inheritdoc}
*/
public function key()
{
return $this->position;
}
/**
* {@inheritdoc}
*/
public function next()
{
if (!$this->elements && $this->fetchmore) {
$this->fetch();
}
if ($this->elements) {
$this->extractNext();
} else {
$this->valid = false;
}
}
/**
* {@inheritdoc}
*/
public function valid()
{
return $this->valid;
}
}
@@ -0,0 +1,249 @@
<?php
/*
* This file is part of the Predis package.
*
* (c) Daniele Alessandri <suppakilla@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Predis\Collection\Iterator;
use \PHPUnit_Framework_TestCase as StandardTestCase;
use Predis\Client;
use Predis\Profile\ServerProfile;
/**
* @group realm-iterators
*/
class ListKeyTest extends StandardTestCase
{
/**
* @group disconnected
*/
public function testIterationWithNoResults()
{
$client = $this->getMock('Predis\Client', array('getProfile', 'lrange'));
$client->expects($this->any())
->method('getProfile')
->will($this->returnValue(ServerProfile::getDefault()));
$client->expects($this->once())
->method('lrange')
->with('key:list', 0, 9)
->will($this->returnValue(array()));
$iterator = new ListKey($client, 'key:list');
$iterator->rewind();
$this->assertFalse($iterator->valid());
}
/**
* @group disconnected
*/
public function testIterationOnSingleFetch()
{
$client = $this->getMock('Predis\Client', array('getProfile', 'lrange'));
$client->expects($this->any())
->method('getProfile')
->will($this->returnValue(ServerProfile::getDefault()));
$client->expects($this->once())
->method('lrange')
->with('key:list', 0, 9)
->will($this->returnValue(array('item:1', 'item:2', 'item:3')));
$iterator = new ListKey($client, 'key:list');
$iterator->rewind();
$this->assertTrue($iterator->valid());
$this->assertSame('item:1', $iterator->current());
$this->assertSame(0, $iterator->key());
$iterator->next();
$this->assertTrue($iterator->valid());
$this->assertSame('item:2', $iterator->current());
$this->assertSame(1, $iterator->key());
$iterator->next();
$this->assertTrue($iterator->valid());
$this->assertSame('item:3', $iterator->current());
$this->assertSame(2, $iterator->key());
$iterator->next();
$this->assertFalse($iterator->valid());
}
/**
* @group disconnected
*/
public function testIterationOnMultipleFetches()
{
$client = $this->getMock('Predis\Client', array('getProfile', 'lrange'));
$client->expects($this->any())
->method('getProfile')
->will($this->returnValue(ServerProfile::getDefault()));
$client->expects($this->at(1))
->method('lrange')
->with('key:list', 0, 9)
->will($this->returnValue(array(
'item:1', 'item:2', 'item:3', 'item:4', 'item:5', 'item:6', 'item:7', 'item:8', 'item:9', 'item:10'
)));
$client->expects($this->at(2))
->method('lrange')
->with('key:list', 10, 19)
->will($this->returnValue(array('item:11', 'item:12')));
$iterator = new ListKey($client, 'key:list');
for ($i = 1, $iterator->rewind(); $i <= 12; $i++, $iterator->next()) {
$this->assertTrue($iterator->valid());
$this->assertSame("item:$i", $iterator->current());
$this->assertSame($i - 1, $iterator->key());
}
$this->assertFalse($iterator->valid());
}
/**
* @group disconnected
* @expectedException InvalidArgumentException
* @expectedExceptionMessage The $count argument must be a positive integer.
*/
public function testThrowsExceptionOnConstructorWithNonIntegerCountParameter()
{
$client = $this->getMock('Predis\ClientInterface');
$client->expects($this->any())
->method('getProfile')
->will($this->returnValue(ServerProfile::getDefault()));
$iterator = new ListKey($client, 'key:list', 'wrong');
}
/**
* @group disconnected
* @expectedException InvalidArgumentException
* @expectedExceptionMessage The $count argument must be a positive integer.
*/
public function testThrowsExceptionOnConstructorWithNegativeCountParameter()
{
$client = $this->getMock('Predis\ClientInterface');
$client->expects($this->any())
->method('getProfile')
->will($this->returnValue(ServerProfile::getDefault()));
$iterator = new ListKey($client, 'key:list', 'wrong');
}
/**
* @group disconnected
*/
public function testIterationWithCountParameter()
{
$client = $this->getMock('Predis\Client', array('getProfile', 'lrange'));
$client->expects($this->any())
->method('getProfile')
->will($this->returnValue(ServerProfile::getDefault()));
$client->expects($this->at(1))
->method('lrange')
->with('key:list', 0, 4)
->will($this->returnValue(array('item:1', 'item:2')));
$iterator = new ListKey($client, 'key:list', 5);
$iterator->rewind();
$this->assertTrue($iterator->valid());
$this->assertSame('item:1', $iterator->current());
$this->assertSame(0, $iterator->key());
$iterator->next();
$this->assertTrue($iterator->valid());
$this->assertSame('item:2', $iterator->current());
$this->assertSame(1, $iterator->key());
$iterator->next();
$this->assertFalse($iterator->valid());
}
/**
* @group disconnected
*/
public function testIterationWithCountParameterOnMultipleFetches()
{
$client = $this->getMock('Predis\Client', array('getProfile', 'lrange'));
$client->expects($this->any())
->method('getProfile')
->will($this->returnValue(ServerProfile::getDefault()));
$client->expects($this->at(1))
->method('lrange')
->with('key:list', 0, 1)
->will($this->returnValue(array('item:1', 'item:2')));
$client->expects($this->at(2))
->method('lrange')
->with('key:list', 2, 3)
->will($this->returnValue(array('item:3')));
$iterator = new ListKey($client, 'key:list', 2);
$iterator->rewind();
$this->assertTrue($iterator->valid());
$this->assertSame('item:1', $iterator->current());
$this->assertSame(0, $iterator->key());
$iterator->next();
$this->assertTrue($iterator->valid());
$this->assertSame('item:2', $iterator->current());
$this->assertSame(1, $iterator->key());
$iterator->next();
$this->assertTrue($iterator->valid());
$this->assertSame('item:3', $iterator->current());
$this->assertSame(2, $iterator->key());
$iterator->next();
$this->assertFalse($iterator->valid());
}
/**
* @group disconnected
*/
public function testIterationRewindable()
{
$client = $this->getMock('Predis\Client', array('getProfile', 'lrange'));
$client->expects($this->any())
->method('getProfile')
->will($this->returnValue(ServerProfile::getDefault()));
$client->expects($this->exactly(2))
->method('lrange')
->with('key:list', 0, 9)
->will($this->returnValue(array('item:1', 'item:2')));
$iterator = new ListKey($client, 'key:list');
$iterator->rewind();
$this->assertTrue($iterator->valid());
$this->assertSame('item:1', $iterator->current());
$this->assertSame(0, $iterator->key());
$iterator->rewind();
$this->assertTrue($iterator->valid());
$this->assertSame('item:1', $iterator->current());
$this->assertSame(0, $iterator->key());
$iterator->next();
$this->assertTrue($iterator->valid());
$this->assertSame(1, $iterator->key());
$this->assertSame('item:2', $iterator->current());
$iterator->next();
$this->assertFalse($iterator->valid());
}
}