Browse Source

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.
Daniele Alessandri 11 years ago
parent
commit
9b8b362747

+ 7 - 1
CHANGELOG.md

@@ -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 - 0
lib/Predis/Collection/Iterator/ListKey.php

@@ -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;
+    }
+}

+ 249 - 0
tests/Predis/Collection/Iterator/ListKeyTest.php

@@ -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());
+    }
+}