Procházet zdrojové kódy

... and here finally comes Predis!

Daniele Alessandri před 15 roky
revize
34a616cd95

+ 22 - 0
LICENSE

@@ -0,0 +1,22 @@
+Copyright (c) 2009 Daniele Alessandri
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.

+ 116 - 0
README.markdown

@@ -0,0 +1,116 @@
+# Predis #
+
+## About ##
+
+Predis is a flexible and feature-complete PHP client library for the Redis key-value 
+database.
+
+Predis is currently a work-in-progress and it targets PHP >= 5.3, though it is highly 
+due to be backported to PHP >= 5.2.6 as soon as the public API and the internal design 
+on the main branch will be considered stable enough.
+
+Please refer to the TODO file to see which issues are still pending and what is due 
+to be implemented soon in Predis.
+
+
+## Features ##
+
+- Client-side sharding (support for consistent hashing of keys)
+- Command pipelining on single and multiple connections (transparent)
+- Lazy connections (connections to Redis instances are only established just in time)
+- Flexible system to define and register your own set of commands to a client instance
+
+
+## Quick examples ##
+
+### Connecting to a local instance of Redis ###
+
+
+    $redis = new Predis\Client();
+    $redis->set('library', 'predis');
+    $value = $redis->get('library');
+
+
+### Pipelining multiple commands to a remote instance of Redis ##
+
+
+    $redis   = new Predis\Client('10.0.0.1', 6379);
+    $replies = $redis->pipeline(function($pipe) {
+        $pipe->ping();
+        $pipe->incrby('counter', 10);
+        $pipe->incrby('counter', 30);
+        $pipe->get('counter');
+    });
+
+
+### Pipelining multiple commands to multiple instances of Redis (sharding) ##
+
+
+    $redis = Predis\Client::createCluster(
+        array('host' => '10.0.0.1', 'port' => 6379),
+        array('host' => '10.0.0.2', 'port' => 6379)
+    );
+
+    $replies = $redis->pipeline(function($pipe) {
+        for ($i = 0; $i < 1000; $i++) {
+            $pipe->set("key:$i", str_pad($i, 4, '0', 0));
+            $pipe->get("key:$i");
+        }
+    });
+
+
+### Definition and runtime registration of new commands on the client ###
+
+
+class BrandNewRedisCommand extends \Predis\InlineCommand {
+    public function getCommandId() { return 'NEWCMD'; }
+}
+
+$redis = new Predis\Client();
+$redis->registerCommand('BrandNewRedisCommand', 'newcmd');
+$redis->newcmd();
+
+
+## Development ##
+
+Predis is fully backed up by a test suite which tries to cover all the aspects of the 
+client library and the interaction of every single command with a Redis server. If you 
+want to work on Predis, it is highly recommended that you first run the test suite to 
+be sure that everything is OK, and report strange behaviours or bugs.
+
+The recommended way to contribute to Predis is to fork the project on GitHub, fix or 
+add features on your newly created repository and then submit issues on the Predis 
+issue tracker with a link to your repository. Obviously, you can use any other Git 
+hosting provider of you preference. Diff patches will be accepted too, even though 
+they are not the preferred way to contribute to Predis.
+
+When modifying Predis plrease be sure that no warning or notices are emitted by PHP by 
+running the interpreter in your development environment with the "error_reporting"
+variable set to E_ALL.
+
+
+## Dependencies ##
+
+- PHP >= 5.3
+- PHPUnit (needed to run the test suite)
+
+## Links ##
+
+### Project ###
+[Source code](https://github.com/nrk/predis/)
+[Issue tracker](http://github.com/nrk/predis/issues)
+
+### Related ###
+[Redis](http://code.google.com/p/redis/)
+[PHP](http://php.net/)
+[PHPUnit](http://www.phpunit.de/)
+[Git](http://git-scm.com/)
+
+## Author ##
+
+[Daniele Alessandri](mailto://suppakilla@gmail.com)
+
+
+## License ##
+
+The code for Predis is distributed under the terms of the MIT license (see LICENSE).

+ 21 - 0
TODO

@@ -0,0 +1,21 @@
+* Authentication and database selection should be handled transparently by 
+  the client.
+
+* The current behaviour of sending, by default, unshardable commands to the 
+  first registered connection of a ConnectionCluster instance needs to be 
+  verified.
+
+* The included test suite covers almost all the Redis server commands, but a 
+  full battery of tests targeting specific functions of this library is still 
+  missing.
+
+* Support for pipelining commands on one or more connections works, but it 
+  could be optimized for better performances with a cache of computed commands 
+  hashes, but the memory impact still needs to be evalued.
+
+* Add the possibility of flushing the command buffer from inside of a pipeline.
+
+* Switching to/from instances of Connection and ConnectionCluster should be 
+  transparent to the user. Using a ConnectionCluster instance when there is 
+  only one active connection has an unnecessary overhead.
+

+ 39 - 0
examples/CommandPipeline.php

@@ -0,0 +1,39 @@
+<?php
+require_once 'SharedConfigurations.php';
+
+// when you have a whole set of consecutive commands to send to 
+// a redis server, you can use a pipeline to improve performances.
+
+$redis = new Predis\Client(REDIS_HOST, REDIS_PORT);
+$redis->select(REDIS_DB);
+
+$replies = $redis->pipeline(function($pipe) {
+    $pipe->ping();
+    $pipe->flushdb();
+    $pipe->incrby('counter', 10);
+    $pipe->incrby('counter', 30);
+    $pipe->exists('counter');
+    $pipe->get('counter');
+    $pipe->mget('does_not_exist', 'counter');
+});
+
+print_r($replies);
+
+/* OUTPUT:
+Array
+(
+    [0] => 1
+    [1] => 1
+    [2] => 10
+    [3] => 40
+    [4] => 1
+    [5] => 40
+    [6] => Array
+        (
+            [0] => 
+            [1] => 40
+        )
+
+)
+*/
+?>

+ 30 - 0
examples/MultipleSetAndGet.php

@@ -0,0 +1,30 @@
+<?php
+require_once 'SharedConfigurations.php';
+
+// redis can set keys and their relative values in one go 
+// using MSET, then the same values can be retrieved with 
+// a single command using MGET.
+
+$mkv = array(
+    'usr:0001' => 'First user',
+    'usr:0002' => 'Second user', 
+    'usr:0003' => 'Third user' 
+);
+
+$redis = new Predis\Client(REDIS_HOST, REDIS_PORT);
+$redis->select(REDIS_DB);
+
+$redis->mset($mkv);
+$retval = $redis->mget(array_keys($mkv));
+
+print_r($retval);
+
+/* OUTPUT:
+Array
+(
+    [0] => First user
+    [1] => Second user
+    [2] => Third user
+)
+*/
+?>

+ 7 - 0
examples/SharedConfigurations.php

@@ -0,0 +1,7 @@
+<?php
+require_once '../lib/Predis.php';
+
+const REDIS_HOST = '192.168.1.205';
+const REDIS_PORT = 6379;
+const REDIS_DB   = 15;
+?>

+ 17 - 0
examples/SimpleSetAndGet.php

@@ -0,0 +1,17 @@
+<?php
+require_once 'SharedConfigurations.php';
+
+// simple set and get scenario
+
+$redis = new Predis\Client(REDIS_HOST, REDIS_PORT);
+$redis->select(REDIS_DB);
+
+$redis->set('library', 'predis');
+$retval = $redis->get('library');
+
+print_r($retval);
+
+/* OUTPUT
+predis
+*/
+?>

+ 1106 - 0
lib/Predis.php

@@ -0,0 +1,1106 @@
+<?php
+namespace Predis;
+
+class PredisException extends \Exception { }
+class ClientException extends PredisException { }
+class ServerException extends PredisException { }
+class PipelineException extends ClientException { }
+class MalformedServerResponse extends ServerException { }
+
+/* ------------------------------------------------------------------------- */
+
+class Client {
+    // TODO: command arguments should be sanitized or checked for bad arguments 
+    //       (e.g. CRLF in keys for inline commands)
+
+    private $_connection, $_registeredCommands, $_pipelining;
+
+    public function __construct($host = Connection::DEFAULT_HOST, $port = Connection::DEFAULT_PORT) {
+        $this->_pipelining = false;
+        $this->_connection = new Connection($host, $port);
+        $this->_registeredCommands = self::initializeDefaultCommands();
+    }
+
+    public function __destruct() {
+        $this->_connection->disconnect();
+    }
+
+    public static function createCluster(/* arguments */) {
+        $cluster = new ConnectionCluster();
+        foreach (func_get_args() as $parameters) {
+            $cluster->add(new Connection($parameters['host'], $parameters['port']));
+        }
+        $client = new Client();
+        $client->setConnection($cluster);
+        return $client;
+    }
+
+    private function setConnection(IConnection $connection) {
+        $this->_connection = $connection;
+    }
+
+    public function connect() {
+        $this->_connection->connect();
+    }
+
+    public function disconnect() {
+        $this->_connection->disconnect();
+    }
+
+    public function isConnected() {
+        return $this->_connection->isConnected();
+    }
+
+    public function getConnection() {
+        return $this->_connection;
+    }
+
+    public function __call($method, $arguments) {
+        $command = $this->createCommandInstance($method, $arguments);
+        return $this->executeCommand($command);
+    }
+
+    public function createCommandInstance($method, $arguments) {
+        $commandClass = $this->_registeredCommands[$method];
+
+        if ($commandClass === null) {
+            throw new ClientException("'$method' is not a registered Redis command");
+        }
+
+        $command = new $commandClass();
+        $command->setArgumentsArray($arguments);
+        return $command;
+    }
+
+    public function executeCommand(Command $command) {
+        if ($this->_pipelining === false) {
+            $this->_connection->writeCommand($command);
+            if ($command->closesConnection()) {
+                return $this->_connection->disconnect();
+            }
+            return $this->_connection->readResponse($command);
+        }
+        else {
+            $this->_pipelineBuffer[] = $command;
+        }
+    }
+
+    public function rawCommand($rawCommandData, $closesConnection = false) {
+        // TODO: rather than check the type of a connection instance, we should 
+        //       check if it does respond to the rawCommand method.
+        if (is_a($this->_connection, '\Predis\ConnectionCluster')) {
+            throw new ClientException('Cannot send raw commands when connected to a cluster of Redis servers');
+        }
+        return $this->_connection->rawCommand($rawCommandData, $closesConnection);
+    }
+
+    public function pipeline(\Closure $pipelineBlock) {
+        $pipelineBlockException = null;
+        $returnValues = array();
+
+        try {
+            $pipeline = new CommandPipeline($this);
+            $this->_pipelining = true;
+            $pipelineBlock($pipeline);
+            // TODO: this should be moved entirely into the 
+            //       self-contained CommandPipeline instance.
+            $recordedCommands = $pipeline->getRecordedCommands();
+
+            foreach ($recordedCommands as $command) {
+                $this->_connection->writeCommand($command);
+            }
+            foreach ($recordedCommands as $command) {
+                $returnValues[] = $this->_connection->readResponse($command);
+            }
+        }
+        catch (\Exception $exception) {
+            $pipelineBlockException = $exception;
+        }
+
+        $this->_pipelining = false;
+
+        if ($pipelineBlockException !== null) {
+            throw new PipelineException('An exception has occurred inside of a pipeline block', 
+                null, $pipelineBlockException);
+        }
+
+        return $returnValues;
+    }
+
+    public function registerCommands(Array $commands) {
+        foreach ($commands as $command => $aliases) {
+            $this->registerCommand($command, $aliases);
+        }
+    }
+
+    public function registerCommand($command, $aliases) {
+        $commandReflection = new \ReflectionClass($command);
+
+        if (!$commandReflection->isSubclassOf('\Predis\Command')) {
+            throw new ClientException("Cannot register '$command' as it is not a valid Redis command");
+        }
+
+        if (is_array($aliases)) {
+            foreach ($aliases as $alias) {
+                $this->_registeredCommands[$alias] = $command;
+            }
+        }
+        else {
+            $this->_registeredCommands[$aliases] = $command;
+        }
+    }
+
+    private static function initializeDefaultCommands() {
+        // NOTE: we don't use \Predis\Client::registerCommands for performance reasons.
+        return array(
+            /* miscellaneous commands */
+            'ping'      => '\Predis\Commands\Ping',
+            'echo'      => '\Predis\Commands\DoEcho',
+            'auth'      => '\Predis\Commands\Auth',
+
+            /* connection handling */
+            'quit'      => '\Predis\Commands\Quit',
+
+            /* commands operating on string values */
+            'set'                     => '\Predis\Commands\Set',
+            'setnx'                   => '\Predis\Commands\SetPreserve',
+                'setPreserve'         => '\Predis\Commands\SetPreserve',
+            'mset'                    => '\Predis\Commands\SetMultiple',  
+                'setMultiple'         => '\Predis\Commands\SetMultiple',
+            'msetnx'                  => '\Predis\Commands\SetMultiplePreserve',
+                'setMultiplePreserve' => '\Predis\Commands\SetMultiplePreserve',
+            'get'                     => '\Predis\Commands\Get',
+            'mget'                    => '\Predis\Commands\GetMultiple',
+                'getMultiple'         => '\Predis\Commands\GetMultiple',
+            'getset'                  => '\Predis\Commands\GetSet',
+                'getSet'              => '\Predis\Commands\GetSet',
+            'incr'                    => '\Predis\Commands\Increment',
+                'increment'           => '\Predis\Commands\Increment',
+            'incrby'                  => '\Predis\Commands\IncrementBy',
+                'incrementBy'         => '\Predis\Commands\IncrementBy',
+            'incr'                    => '\Predis\Commands\Decrement',
+                'decrement'           => '\Predis\Commands\Decrement',
+            'decrby'                  => '\Predis\Commands\DecrementBy',
+                'decrementBy'         => '\Predis\Commands\DecrementBy',
+            'exists'                  => '\Predis\Commands\Exists',
+            'del'                     => '\Predis\Commands\Delete',
+                'delete'              => '\Predis\Commands\Delete',
+            'type'                    => '\Predis\Commands\Type',
+
+            /* commands operating on the key space */
+            'keys'               => '\Predis\Commands\Keys',
+            'randomkey'          => '\Predis\Commands\RandomKey',
+                'randomKey'      => '\Predis\Commands\RandomKey',
+            'rename'             => '\Predis\Commands\Rename',
+            'renamenx'           => '\Predis\Commands\RenamePreserve',
+                'renamePreserve' => '\Predis\Commands\RenamePreserve',
+            'expire'             => '\Predis\Commands\Expire',
+            'expireat'           => '\Predis\Commands\ExpireAt',
+                'expireAt'       => '\Predis\Commands\ExpireAt',
+            'dbsize'             => '\Predis\Commands\DatabaseSize',
+                'databaseSize'   => '\Predis\Commands\DatabaseSize',
+            'ttl'                => '\Predis\Commands\TimeToLive',
+                'timeToLive'     => '\Predis\Commands\TimeToLive',
+
+            /* commands operating on lists */
+            'rpush'            => '\Predis\Commands\ListPushTail',
+                'pushTail'     => '\Predis\Commands\ListPushTail',
+            'lpush'            => '\Predis\Commands\ListPushHead',
+                'pushHead'     => '\Predis\Commands\ListPushHead',
+            'llen'             => '\Predis\Commands\ListLength',
+                'listLength'   => '\Predis\Commands\ListLength',
+            'lrange'           => '\Predis\Commands\ListRange',
+                'listRange'    => '\Predis\Commands\ListRange',
+            'ltrim'            => '\Predis\Commands\ListTrim',
+                'listTrim'     => '\Predis\Commands\ListTrim',
+            'lindex'           => '\Predis\Commands\ListIndex',
+                'listIndex'    => '\Predis\Commands\ListIndex',
+            'lset'             => '\Predis\Commands\ListSet',
+                'listSet'      => '\Predis\Commands\ListSet',
+            'lrem'             => '\Predis\Commands\ListRemove',
+                'listRemove'   => '\Predis\Commands\ListRemove',
+            'lpop'             => '\Predis\Commands\ListPopFirst',
+                'popFirst'     => '\Predis\Commands\ListPopFirst',
+            'rpop'             => '\Predis\Commands\ListPopLast',
+                'popLast'      => '\Predis\Commands\ListPopLast',
+
+            /* commands operating on sets */
+            'sadd'                      => '\Predis\Commands\SetAdd', 
+                'setAdd'                => '\Predis\Commands\SetAdd',
+            'srem'                      => '\Predis\Commands\SetRemove', 
+                'setRemove'             => '\Predis\Commands\SetRemove',
+            'spop'                      => '\Predis\Commands\SetPop',
+                'setPop'                => '\Predis\Commands\SetPop',
+            'smove'                     => '\Predis\Commands\SetMove', 
+                'setMove'               => '\Predis\Commands\SetMove',
+            'scard'                     => '\Predis\Commands\SetCardinality', 
+                'setCardinality'        => '\Predis\Commands\SetCardinality',
+            'sismember'                 => '\Predis\Commands\SetIsMember', 
+                'setIsMember'           => '\Predis\Commands\SetIsMember',
+            'sinter'                    => '\Predis\Commands\SetIntersection', 
+                'setIntersection'       => '\Predis\Commands\SetIntersection',
+            'sinterstore'               => '\Predis\Commands\SetIntersectionStore', 
+                'setIntersectionStore'  => '\Predis\Commands\SetIntersectionStore',
+            'sunion'                    => '\Predis\Commands\SetUnion', 
+                'setUnion'              => '\Predis\Commands\SetUnion',
+            'sunionstore'               => '\Predis\Commands\SetUnionStore', 
+                'setUnionStore'         => '\Predis\Commands\SetUnionStore',
+            'sdiff'                     => '\Predis\Commands\SetDifference', 
+                'setDifference'         => '\Predis\Commands\SetDifference',
+            'sdiffstore'                => '\Predis\Commands\SetDifferenceStore', 
+                'setDifferenceStore'    => '\Predis\Commands\SetDifferenceStore',
+            'smembers'                  => '\Predis\Commands\SetMembers', 
+                'setMembers'            => '\Predis\Commands\SetMembers',
+            'srandmember'               => '\Predis\Commands\SetRandomMember', 
+                'setRandomMember'       => '\Predis\Commands\SetRandomMember',
+
+            /* commands operating on sorted sets */
+            'zadd'                          => '\Predis\Commands\ZSetAdd', 
+                'zsetAdd'                   => '\Predis\Commands\ZSetAdd',
+            'zrem'                          => '\Predis\Commands\ZSetRemove', 
+                'zsetRemove'                => '\Predis\Commands\ZSetRemove',
+            'zrange'                        => '\Predis\Commands\ZSetRange', 
+                'zsetRange'                 => '\Predis\Commands\ZSetRange',
+            'zrevrange'                     => '\Predis\Commands\ZSetReverseRange', 
+                'zsetReverseRange'          => '\Predis\Commands\ZSetReverseRange',
+            'zrangebyscore'                 => '\Predis\Commands\ZSetRangeByScore', 
+                'zsetRangeByScore'          => '\Predis\Commands\ZSetRangeByScore',
+            'zcard'                         => '\Predis\Commands\ZSetCardinality', 
+                'zsetCardinality'           => '\Predis\Commands\ZSetCardinality',
+            'zscore'                        => '\Predis\Commands\ZSetScore', 
+                'zsetScore'                 => '\Predis\Commands\ZSetScore',
+            'zremrangebyscore'              => '\Predis\Commands\ZSetRemoveRangeByScore', 
+                'zsetRemoveRangeByScore'    => '\Predis\Commands\ZSetRemoveRangeByScore',
+
+            /* multiple databases handling commands */
+            'select'                => '\Predis\Commands\SelectDatabase', 
+                'selectDatabase'    => '\Predis\Commands\SelectDatabase',
+            'move'                  => '\Predis\Commands\MoveKey', 
+                'moveKey'           => '\Predis\Commands\MoveKey',
+            'flushdb'               => '\Predis\Commands\FlushDatabase', 
+                'flushDatabase'     => '\Predis\Commands\FlushDatabase',
+            'flushall'              => '\Predis\Commands\FlushAll', 
+                'flushDatabases'    => '\Predis\Commands\FlushAll',
+
+            /* sorting */
+            'sort'                  => '\Predis\Commands\Sort',
+
+            /* remote server control commands */
+            'info'                  => '\Predis\Commands\Info',
+            'slaveof'               => '\Predis\Commands\SlaveOf', 
+                'slaveOf'           => '\Predis\Commands\SlaveOf',
+
+            /* persistence control commands */
+            'save'                  => '\Predis\Commands\Save',
+            'bgsave'                => '\Predis\Commands\BackgroundSave', 
+                'backgroundSave'    => '\Predis\Commands\BackgroundSave',
+            'lastsave'              => '\Predis\Commands\LastSave', 
+                'lastSave'          => '\Predis\Commands\LastSave',
+            'shutdown'              => '\Predis\Commands\Shutdown'
+        );
+    }
+}
+
+/* ------------------------------------------------------------------------- */
+
+abstract class Command {
+    private $_arguments;
+
+    public abstract function getCommandId();
+
+    public abstract function serializeRequest($command, $arguments);
+
+    public function canBeHashed() {
+        return true;
+    }
+
+    public function closesConnection() {
+        return false;
+    }
+
+    protected function filterArguments(Array $arguments) {
+        return $arguments;
+    }
+
+    public function setArguments(/* arguments */) {
+        $this->_arguments = $this->filterArguments(func_get_args());
+    }
+
+    public function setArgumentsArray(Array $arguments) {
+        $this->_arguments = $this->filterArguments($arguments);
+    }
+
+    protected function getArguments() {
+        return $this->_arguments !== null ? $this->_arguments : array();
+    }
+
+    public function getArgument($index = 0) {
+        return $this->_arguments !== null ? $this->_arguments[$index] : null;
+    }
+
+    public function parseResponse($data) {
+        return $data;
+    }
+
+    public final function __invoke() {
+        return $this->serializeRequest($this->getCommandId(), $this->getArguments());
+    }
+}
+
+abstract class InlineCommand extends Command {
+    public function serializeRequest($command, $arguments) {
+        if (isset($arguments[0]) && is_array($arguments[0])) {
+            $arguments[0] = implode($arguments[0], ' ');
+        }
+        return $command . ' ' . implode($arguments, ' ') . Response::NEWLINE;
+    }
+}
+
+abstract class BulkCommand extends Command {
+    public function serializeRequest($command, $arguments) {
+        $data = array_pop($arguments);
+        if (is_array($data)) {
+            $data = implode($data, ' ');
+        }
+        return $command . ' ' . implode($arguments, ' ') . ' ' . strlen($data) . 
+            Response::NEWLINE . $data . Response::NEWLINE;
+    }
+}
+
+abstract class MultiBulkCommand extends Command {
+    public function serializeRequest($command, $arguments) {
+        $buffer   = array();
+        $cmd_args = null;
+
+        if (count($arguments) === 1 && is_array($arguments[0])) {
+            $cmd_args = array();
+            foreach ($arguments[0] as $k => $v) {
+                $cmd_args[] = $k;
+                $cmd_args[] = $v;
+            }
+        }
+        else {
+            $cmd_args = $arguments;
+        }
+
+        $buffer[] = '*' . ((string) count($cmd_args) + 1) . Response::NEWLINE;
+        $buffer[] = '$' . strlen($command) . Response::NEWLINE . $command . Response::NEWLINE;
+        foreach ($cmd_args as $argument) {
+            $buffer[] = '$' . strlen($argument) . Response::NEWLINE . $argument . Response::NEWLINE;
+        }
+
+        return implode('', $buffer);
+    }
+}
+
+/* ------------------------------------------------------------------------- */
+
+class Response {
+    const NEWLINE = "\r\n";
+    const OK      = 'OK';
+    const ERROR   = 'ERR';
+    const NULL    = 'nil';
+
+    private static $_prefixHandlers;
+
+    private static function initializePrefixHandlers() {
+        return array(
+            // status
+            '+' => function($socket) {
+                $status = rtrim(fgets($socket), Response::NEWLINE);
+                return $status === Response::OK ? true : $status;
+            }, 
+
+            // error
+            '-' => function($socket) {
+                $errorMessage = rtrim(fgets($socket), Response::NEWLINE);
+                throw new ServerException(substr($errorMessage, 4));
+            }, 
+
+            // bulk
+            '$' => function($socket) {
+                $dataLength = rtrim(fgets($socket), Response::NEWLINE);
+
+                if (!is_numeric($dataLength)) {
+                    throw new ClientException("Cannot parse '$dataLength' as data length");
+                }
+
+                if ($dataLength > 0) {
+                    $value = fread($socket, $dataLength);
+                    fread($socket, 2);
+                    return $value;
+                }
+                else if ($dataLength == 0) {
+                    // TODO: I just have a doubt here...
+                    fread($socket, 2);
+                }
+
+                return null;
+            }, 
+
+            // multibulk
+            '*' => function($socket) {
+                $rawLength = rtrim(fgets($socket), Response::NEWLINE);
+                if (!is_numeric($rawLength)) {
+                    throw new ClientException("Cannot parse '$rawLength' as data length");
+                }
+
+                $listLength = (int) $rawLength;
+                if ($listLength === -1) {
+                    return null;
+                }
+
+                $list = array();
+
+                if ($listLength > 0) {
+                    for ($i = 0; $i < $listLength; $i++) {
+                        $handler = Response::getPrefixHandler(fgetc($socket));
+                        $list[] = $handler($socket);
+                    }
+                }
+
+                return $list;
+            }, 
+
+            // integer
+            ':' => function($socket) {
+                $number = rtrim(fgets($socket), Response::NEWLINE);
+                if (is_numeric($number)) {
+                    return (int) $number;
+                }
+                else {
+                    if ($number !== Response::NULL) {
+                        throw new ClientException("Cannot parse '$number' as numeric response");
+                    }
+                    return null;
+                }
+            }
+        );
+    }
+
+    public static function getPrefixHandler($prefix) {
+        if (self::$_prefixHandlers == null) {
+            self::$_prefixHandlers = self::initializePrefixHandlers();
+        }
+
+        $handler = self::$_prefixHandlers[$prefix];
+        if ($handler === null) {
+            throw new MalformedServerResponse("Unknown prefix '$prefix'");
+        }
+        return $handler;
+    }
+}
+
+class CommandPipeline {
+    private $_redisClient, $_pipelineBuffer;
+
+    public function __construct(Client $redisClient) {
+        $this->_redisClient    = $redisClient;
+        $this->_pipelineBuffer = array();
+    }
+
+    public function __call($method, $arguments) {
+        $command = $this->_redisClient->createCommandInstance($method, $arguments);
+        $this->registerCommand($command);
+    }
+
+    private function registerCommand(Command $command) {
+        $this->_pipelineBuffer[] = $command;
+    }
+
+    public function getRecordedCommands() {
+        return $this->_pipelineBuffer;
+    }
+}
+
+/* ------------------------------------------------------------------------- */
+
+interface IConnection {
+    public function connect();
+    public function disconnect();
+    public function isConnected();
+    public function writeCommand(Command $command);
+    public function readResponse(Command $command);
+}
+
+class Connection implements IConnection {
+    const DEFAULT_HOST = '127.0.0.1';
+    const DEFAULT_PORT = 6379;
+    const CONNECTION_TIMEOUT = 2;
+    const READ_WRITE_TIMEOUT = 5;
+
+    private $_host, $_port, $_socket;
+
+    public function __construct($host = self::DEFAULT_HOST, $port = self::DEFAULT_PORT) {
+        $this->_host = $host;
+        $this->_port = $port;
+    }
+
+    public function __destruct() {
+        $this->disconnect();
+    }
+
+    public function isConnected() {
+        return is_resource($this->_socket);
+    }
+
+    public function connect() {
+        if ($this->isConnected()) {
+            throw new ClientException('Connection already estabilished');
+        }
+        $uri = sprintf('tcp://%s:%d/', $this->_host, $this->_port);
+        $this->_socket = @stream_socket_client($uri, $errno, $errstr, self::CONNECTION_TIMEOUT);
+        if (!$this->_socket) {
+            throw new ClientException(trim($errstr), $errno);
+        }
+        stream_set_timeout($this->_socket, self::READ_WRITE_TIMEOUT);
+    }
+
+    public function disconnect() {
+        if ($this->isConnected()) {
+            fclose($this->_socket);
+        }
+    }
+
+    public function writeCommand(Command $command) {
+        fwrite($this->getSocket(), $command());
+    }
+
+    public function readResponse(Command $command) {
+        $socket   = $this->getSocket();
+        $handler  = Response::getPrefixHandler(fgetc($socket));
+        $response = $command->parseResponse($handler($socket));
+        return $response;
+    }
+
+    public function rawCommand($rawCommandData, $closesConnection = false) {
+        $socket = $this->getSocket();
+        fwrite($socket, $rawCommandData);
+        if ($closesConnection) {
+            return;
+        }
+        $handler = Response::getPrefixHandler(fgetc($socket));
+        return $handler($socket);
+    }
+
+    public function getSocket() {
+        if (!$this->isConnected()) {
+            $this->connect();
+        }
+        return $this->_socket;
+    }
+
+    public function __toString() {
+        return sprintf('tcp://%s:%d/', $this->_host, $this->_port);
+    }
+}
+
+class ConnectionCluster implements IConnection  {
+    // TODO: storing a temporary map of commands hashes to hashring items (that 
+    //       is, connections) could offer a notable speedup, but I am wondering 
+    //       about the increased memory footprint.
+    // TODO: find a clean way to handle connection failures of single nodes.
+
+    private $_pool, $_ring;
+
+    public function __construct() {
+        $this->_pool = array();
+        $this->_ring = new Utilities\HashRing();
+    }
+
+    public function __destruct() {
+        $this->disconnect();
+    }
+
+    public function isConnected() {
+        foreach ($this->_pool as $connection) {
+            if ($connection->isConnected()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public function connect() {
+        foreach ($this->_pool as $connection) {
+            $connection->connect();
+        }
+    }
+
+    public function disconnect() {
+        foreach ($this->_pool as $connection) {
+            $connection->disconnect();
+        }
+    }
+
+    public function add(Connection $connection) {
+        $this->_pool[] = $connection;
+        $this->_ring->add($connection);
+    }
+
+    private function getConnectionFromRing(Command $command) {
+        return $this->_ring->get($this->computeHash($command));
+    }
+
+    private function computeHash(Command $command) {
+        return crc32($command->getArgument(0));
+    }
+
+    private function getConnection(Command $command) {
+        return $command->canBeHashed() 
+            ? $this->getConnectionFromRing($command) 
+            : $this->getConnectionById(0);
+    }
+
+    public function getConnectionById($id = null) {
+        return $this->_pool[$id === null ? 0 : $id];
+    }
+
+    public function writeCommand(Command $command) {
+        $this->getConnection($command)->writeCommand($command);
+    }
+
+    public function readResponse(Command $command) {
+        return $this->getConnection($command)->readResponse($command);
+    }
+}
+
+/* ------------------------------------------------------------------------- */
+
+namespace Predis\Utilities;
+
+class HashRing {
+    const NUMBER_OF_REPLICAS = 64;
+    private $_ring, $_ringKeys;
+
+    public function __construct() {
+        $this->_ring     = array();
+        $this->_ringKeys = array();
+    }
+
+    public function add($node) {
+        for ($i = 0; $i < self::NUMBER_OF_REPLICAS; $i++) {
+            $key = crc32((string)$node . ':' . $i);
+            $this->_ring[$key] = $node;
+        }
+        ksort($this->_ring, SORT_NUMERIC);
+        $this->_ringKeys = array_keys($this->_ring);
+    }
+
+    public function remove($node) {
+        for ($i = 0; $i < self::NUMBER_OF_REPLICAS; $i++) {
+            $key = crc32((string)$node . '_' . $i);
+            unset($this->_ring[$key]);
+            $this->_ringKeys = array_filter($this->_ringKeys, function($rk) use($key) {
+                return $rk !== $key;
+            });
+        }
+    }
+
+    public function get($key) {
+        return $this->_ring[$this->getNodeKey($key)];
+    }
+
+    private function getNodeKey($key) {
+        $upper = count($this->_ringKeys) - 1;
+        $lower = 0;
+        $index = 0;
+
+        while ($lower <= $upper) {
+            $index = ($lower + $upper) / 2;
+            $item  = $this->_ringKeys[$index];
+            if ($item === $key) {
+                return $index;
+            }
+            else if ($item > $key) {
+                $upper = $index - 1;
+            }
+            else {
+                $lower = $index + 1;
+            }
+        }
+        return $this->_ringKeys[$upper];
+    }
+}
+
+/* ------------------------------------------------------------------------- */
+
+namespace Predis\Commands;
+
+/* miscellaneous commands */
+class Ping extends  \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'PING'; }
+    public function parseResponse($data) {
+        return $data === 'PONG' ? true : false;
+    }
+}
+
+class DoEcho extends \Predis\BulkCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'ECHO'; }
+}
+
+class Auth extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'AUTH'; }
+}
+
+/* connection handling */
+class Quit extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'QUIT'; }
+    public function closesConnection() { return true; }
+}
+
+/* commands operating on string values */
+class Set extends \Predis\MultiBulkCommand {
+    public function getCommandId() { return 'SET'; }
+}
+
+class SetPreserve extends \Predis\BulkCommand {
+    public function getCommandId() { return 'SETNX'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class SetMultiple extends \Predis\MultiBulkCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'MSET'; }
+}
+
+class SetMultiplePreserve extends \Predis\MultiBulkCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'MSETNX'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class Get extends \Predis\MultiBulkCommand {
+    public function getCommandId() { return 'GET'; }
+}
+
+class GetMultiple extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'MGET'; }
+}
+
+class GetSet extends \Predis\BulkCommand {
+    public function getCommandId() { return 'GETSET'; }
+}
+
+class Increment extends \Predis\InlineCommand {
+    public function getCommandId() { return 'INCR'; }
+}
+
+class IncrementBy extends \Predis\InlineCommand {
+    public function getCommandId() { return 'INCRBY'; }
+}
+
+class Decrement extends \Predis\InlineCommand {
+    public function getCommandId() { return 'DECR'; }
+}
+
+class DecrementBy extends \Predis\InlineCommand {
+    public function getCommandId() { return 'DECRBY'; }
+}
+
+class Exists extends \Predis\InlineCommand {
+    public function getCommandId() { return 'EXISTS'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class Delete extends \Predis\InlineCommand {
+    public function getCommandId() { return 'DEL'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class Type extends \Predis\InlineCommand {
+    public function getCommandId() { return 'TYPE'; }
+}
+
+/* commands operating on the key space */
+class Keys extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'KEYS'; }
+    public function parseResponse($data) { 
+        // TODO: is this behaviour correct?
+        return strlen($data) > 0 ? explode(' ', $data) : array();
+    }
+}
+
+class RandomKey extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'RANDOMKEY'; }
+    public function parseResponse($data) { return $data !== '' ? $data : null; }
+}
+
+class Rename extends \Predis\InlineCommand {
+    // TODO: doesn't RENAME break the hash-based client-side sharding?
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'RENAME'; }
+}
+
+class RenamePreserve extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'RENAMENX'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class Expire extends \Predis\InlineCommand {
+    public function getCommandId() { return 'EXPIRE'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class ExpireAt extends \Predis\InlineCommand {
+    public function getCommandId() { return 'EXPIREAT'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class DatabaseSize extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'DBSIZE'; }
+}
+
+class TimeToLive extends \Predis\InlineCommand {
+    public function getCommandId() { return 'TTL'; }
+}
+
+/* commands operating on lists */
+class ListPushTail extends \Predis\BulkCommand {
+    public function getCommandId() { return 'RPUSH'; }
+}
+
+class ListPushHead extends \Predis\BulkCommand {
+    public function getCommandId() { return 'LPUSH'; }
+}
+
+class ListLength extends \Predis\InlineCommand {
+    public function getCommandId() { return 'LLEN'; }
+}
+
+class ListRange extends \Predis\InlineCommand {
+    public function getCommandId() { return 'LRANGE'; }
+}
+
+class ListTrim extends \Predis\InlineCommand {
+    public function getCommandId() { return 'LTRIM'; }
+}
+
+class ListIndex extends \Predis\InlineCommand {
+    public function getCommandId() { return 'LINDEX'; }
+}
+
+class ListSet extends \Predis\BulkCommand {
+    public function getCommandId() { return 'LSET'; }
+}
+
+class ListRemove extends \Predis\BulkCommand {
+    public function getCommandId() { return 'LREM'; }
+}
+
+class ListPopFirst extends \Predis\InlineCommand {
+    public function getCommandId() { return 'LPOP'; }
+}
+
+class ListPopLast extends \Predis\InlineCommand {
+    public function getCommandId() { return 'RPOP'; }
+}
+
+/* commands operating on sets */
+class SetAdd extends \Predis\BulkCommand {
+    public function getCommandId() { return 'SADD'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class SetRemove extends \Predis\BulkCommand {
+    public function getCommandId() { return 'SREM'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class SetPop  extends \Predis\InlineCommand {
+    public function getCommandId() { return 'SPOP'; }
+}
+
+class SetMove extends \Predis\BulkCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'SMOVE'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class SetCardinality extends \Predis\InlineCommand {
+    public function getCommandId() { return 'SCARD'; }
+}
+
+class SetIsMember extends \Predis\BulkCommand {
+    public function getCommandId() { return 'SISMEMBER'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class SetIntersection extends \Predis\InlineCommand {
+    public function getCommandId() { return 'SINTER'; }
+}
+
+class SetIntersectionStore extends \Predis\InlineCommand {
+    public function getCommandId() { return 'SINTERSTORE'; }
+}
+
+class SetUnion extends \Predis\InlineCommand {
+    public function getCommandId() { return 'SUNION'; }
+}
+
+class SetUnionStore extends \Predis\InlineCommand {
+    public function getCommandId() { return 'SUNIONSTORE'; }
+}
+
+class SetDifference extends \Predis\InlineCommand {
+    public function getCommandId() { return 'SDIFF'; }
+}
+
+class SetDifferenceStore extends \Predis\InlineCommand {
+    public function getCommandId() { return 'SDIFFSTORE'; }
+}
+
+class SetMembers extends \Predis\InlineCommand {
+    public function getCommandId() { return 'SMEMBERS'; }
+}
+
+class SetRandomMember extends \Predis\InlineCommand {
+    public function getCommandId() { return 'SRANDMEMBER'; }
+}
+
+/* commands operating on sorted sets */
+class ZSetAdd extends \Predis\BulkCommand {
+    public function getCommandId() { return 'ZADD'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class ZSetRemove extends \Predis\BulkCommand {
+    public function getCommandId() { return 'ZREM'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class ZSetRange extends \Predis\InlineCommand {
+    public function getCommandId() { return 'ZRANGE'; }
+}
+
+class ZSetReverseRange extends \Predis\InlineCommand {
+    public function getCommandId() { return 'ZREVRANGE'; }
+}
+
+class ZSetRangeByScore extends \Predis\InlineCommand {
+    public function getCommandId() { return 'ZRANGEBYSCORE'; }
+}
+
+class ZSetCardinality extends \Predis\InlineCommand {
+    public function getCommandId() { return 'ZCARD'; }
+}
+
+class ZSetScore extends \Predis\BulkCommand {
+    public function getCommandId() { return 'ZSCORE'; }
+}
+
+class ZSetRemoveRangeByScore extends \Predis\InlineCommand {
+    public function getCommandId() { return 'ZREMRANGEBYSCORE'; }
+}
+
+/* multiple databases handling commands */
+class SelectDatabase extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'SELECT'; }
+}
+
+class MoveKey extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'MOVE'; }
+    public function parseResponse($data) { return (bool) $data; }
+}
+
+class FlushDatabase extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'FLUSHDB'; }
+}
+
+class FlushAll extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'FLUSHALL'; }
+}
+
+/* sorting */
+class Sort extends \Predis\InlineCommand {
+    public function getCommandId() { return 'SORT'; }
+    public function filterArguments($arguments) {
+        if (count($arguments) === 1) {
+            return $arguments;
+        }
+
+        // TODO: add more parameters checks
+        $query = array($arguments[0]);
+        $sortParams = $arguments[1];
+
+        if (isset($sortParams['by'])) {
+            $query[] = 'BY ' . $sortParams['by'];
+        }
+        if (isset($sortParams['get'])) {
+            $query[] = 'GET ' . $sortParams['get'];
+        }
+        if (isset($sortParams['limit']) && is_array($sortParams['limit'])) {
+            $query[] = 'LIMIT ' . $sortParams['limit'][0] . ' ' . $sortParams['limit'][1];
+        }
+        if (isset($sortParams['sort'])) {
+            $query[] = strtoupper($sortParams['sort']);
+        }
+        if (isset($sortParams['alpha']) && $sortParams['alpha'] == true) {
+            $query[] = 'ALPHA';
+        }
+        if (isset($sortParams['store']) && $sortParams['store'] == true) {
+            $query[] = 'STORE ' . $sortParams['store'];
+        }
+
+        return $query;
+    }
+}
+
+/* persistence control commands */
+class Save extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'SAVE'; }
+}
+
+class BackgroundSave extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'BGSAVE'; }
+}
+
+class LastSave extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'LASTSAVE'; }
+}
+
+class Shutdown extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'SHUTDOWN'; }
+    public function closesConnection() { return true; }
+}
+
+/* remote server control commands */
+class Info extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'INFO'; }
+    public function parseResponse($data) {
+        $info      = array();
+        $infoLines = explode("\r\n", $data, -1);
+        foreach ($infoLines as $row) {
+            list($k, $v) = explode(':', $row);
+            $info[$k] = $v;
+        }
+        return $info;
+    }
+}
+
+class SlaveOf extends \Predis\InlineCommand {
+    public function canBeHashed()  { return false; }
+    public function getCommandId() { return 'SLAVEOF'; }
+    public function filterArguments($arguments) {
+        return count($arguments) === 0 ? array('NO ONE') : $arguments;
+    }
+}
+?>

+ 124 - 0
test/PredisShared.php

@@ -0,0 +1,124 @@
+<?php
+require_once '../lib/Predis.php';
+
+if (I_AM_AWARE_OF_THE_DESTRUCTIVE_POWER_OF_THIS_TEST_SUITE !== true) {
+    exit('Please set the I_AM_AWARE_OF_THE_DESTRUCTIVE_POWER_OF_THIS_TEST_SUITE constant to TRUE if you want to proceed.');
+}
+
+if (!function_exists('array_union')) {
+    function array_union(Array $a, Array $b) { 
+        return array_merge($a, array_diff($b, $a));
+    }
+}
+
+class RC {
+    const SERVER_HOST      = '127.0.0.1';
+    const SERVER_PORT      = 6379;
+    const DEFAULT_DATABASE = 15;
+
+    const WIPE_OUT         = 1;
+    const EXCEPTION_WRONG_TYPE     = 'Operation against a key holding the wrong kind of value';
+    const EXCEPTION_NO_SUCH_KEY    = 'no such key';
+    const EXCEPTION_OUT_OF_RANGE   = 'index out of range';
+    const EXCEPTION_INVALID_DB_IDX = 'invalid DB index';
+
+    private static $_connection;
+
+    private static function createConnection() {
+        $connection = new Predis\Client(RC::SERVER_HOST, RC::SERVER_PORT);
+        $connection->connect();
+        $connection->selectDatabase(RC::DEFAULT_DATABASE);
+        return $connection;
+    }
+
+    public static function getConnection() {
+        if (self::$_connection === null || !self::$_connection->isConnected()) {
+            self::$_connection = self::createConnection();
+        }
+        return self::$_connection;
+    }
+
+    public static function resetConnection() {
+        if (self::$_connection !== null && self::$_connection->isConnected()) {
+            self::$_connection->disconnect();
+            self::$_connection = self::createConnection();
+        }
+    }
+
+    public static function getArrayOfNumbers() {
+        return array(0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
+    }
+
+    public static function getKeyValueArray() {
+        return array(
+            'foo'      => 'bar', 
+            'hoge'     => 'piyo', 
+            'foofoo'   => 'barbar', 
+        );
+    }
+
+    public static function getNamespacedKeyValueArray() {
+        return array(
+            'metavar:foo'      => 'bar', 
+            'metavar:hoge'     => 'piyo', 
+            'metavar:foofoo'   => 'barbar', 
+        );
+    }
+
+    public static function getZSetArray() {
+        return array(
+            'a' => -10, 'b' => 0, 'c' => 10, 'd' => 20, 'e' => 20, 'f' => 30
+        );
+    }
+
+    public static function sameValuesInArrays($arrayA, $arrayB) {
+        if (count($arrayA) != count($arrayB)) {
+            return false;
+        }
+        return count(array_diff($arrayA, $arrayB)) == 0;
+    }
+
+    public static function testForServerException($testcaseInstance, $expectedMessage, $wrapFunction) {
+        $thrownException = null;
+        try {
+            $wrapFunction($testcaseInstance);
+        }
+        catch (Predis\ServerException $exception) {
+            $thrownException = $exception;
+        }
+        $testcaseInstance->assertType('Predis\ServerException', $thrownException);
+        $testcaseInstance->assertEquals($expectedMessage, $thrownException->getMessage());
+    }
+
+    public static function pushTailAndReturn(Predis\Client $client, $keyName, Array $values, $wipeOut = 0) {
+        if ($wipeOut == true) {
+            $client->delete($keyName);
+        }
+        foreach ($values as $value) {
+            $client->pushTail($keyName, $value);
+        }
+        return $values;
+    }
+
+    public static function setAddAndReturn(Predis\Client $client, $keyName, Array $values, $wipeOut = 0) {
+        if ($wipeOut == true) {
+            $client->delete($keyName);
+        }
+        foreach ($values as $value) {
+            $client->setAdd($keyName, $value);
+        }
+        return $values;
+    }
+
+    public static function zsetAddAndReturn(Predis\Client $client, $keyName, Array $values, $wipeOut = 0) {
+        // $values: array(SCORE => VALUE, ...);
+        if ($wipeOut == true) {
+            $client->delete($keyName);
+        }
+        foreach ($values as $value => $score) {
+            $client->zsetAdd($keyName, $score, $value);
+        }
+        return $values;
+    }
+}
+?>

+ 1213 - 0
test/RedisCommandsTest.php

@@ -0,0 +1,1213 @@
+<?php
+define('I_AM_AWARE_OF_THE_DESTRUCTIVE_POWER_OF_THIS_TEST_SUITE', false);
+
+require_once 'PHPUnit/Framework.php';
+require_once 'PredisShared.php';
+
+class RedisCommandTestSuite extends PHPUnit_Framework_TestCase {
+    public $redis;
+
+    // TODO: instead of an boolean assertion against the return value 
+    //       of RC::sameValuesInArrays, we should extend PHPUnit with 
+    //       a new assertion, e.g. $this->assertSameValues();
+    // TODO: an option to skip certain tests such as testFlushDatabases
+    //       should be provided.
+    // TODO: missing test with float values for a few commands
+
+    protected function setUp() { 
+        $this->redis = RC::getConnection();
+        $this->redis->flushDatabase();
+    }
+
+    protected function tearDown() { 
+    }
+
+    protected function onNotSuccessfulTest($exception) {
+        // drops and reconnect to a redis server on uncaught exceptions
+        RC::resetConnection();
+        parent::onNotSuccessfulTest($exception);
+    }
+
+
+    /* miscellaneous commands */
+
+    function testPing() {
+        $this->assertTrue($this->redis->ping());
+    }
+
+    function testEcho() {
+        $string = 'This is an echo test!';
+        $this->assertEquals($string, $this->redis->echo($string));
+    }
+
+    function testQuit() {
+        $this->redis->quit();
+        $this->assertFalse($this->redis->isConnected());
+    }
+
+
+    /* commands operating on string values */
+
+    function testSet() {
+        $this->assertTrue($this->redis->set('foo', 'bar'));
+        $this->assertEquals('bar', $this->redis->get('foo'));
+    }
+
+    function testGet() {
+        $this->redis->set('foo', 'bar');
+
+        $this->assertEquals('bar', $this->redis->get('foo'));
+        $this->assertNull($this->redis->get('fooDoesNotExist'));
+
+        // should throw an exception when trying to do a GET on non-string types
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->pushTail('metavars', 'foo');
+            $test->redis->get('metavars');
+        });
+    }
+
+    function testExists() {
+        $this->redis->set('foo', 'bar');
+
+        $this->assertTrue($this->redis->exists('foo'));
+        $this->assertFalse($this->redis->exists('key_does_not_exist'));
+    }
+
+    function testSetPreserve() {
+        $multi = RC::getKeyValueArray();
+
+        $this->assertTrue($this->redis->setPreserve('foo', 'bar'));
+        $this->assertFalse($this->redis->setPreserve('foo', 'rab'));
+        $this->assertEquals('bar', $this->redis->get('foo'));
+    }
+
+    function testMultipleSetAndGet() {
+        $multi = RC::getKeyValueArray();
+
+        // key=>value pairs via array instance
+        $this->assertTrue($this->redis->setMultiple($multi));
+        $multiRet = $this->redis->getMultiple(array_keys($multi));
+        $this->assertEquals($multi, array_combine(array_keys($multi), array_values($multiRet)));
+
+        // key=>value pairs via function arguments
+        $this->assertTrue($this->redis->setMultiple('a', 1, 'b', 2, 'c', 3));
+        $this->assertEquals(array(1, 2, 3), $this->redis->getMultiple('a', 'b', 'c'));
+    }
+
+    function testSetMultiplePreserve() {
+        $multi    = RC::getKeyValueArray();
+        $newpair  = array('hogehoge' => 'piyopiyo');
+        $hijacked = array('foo' => 'baz', 'hoge' => 'fuga');
+
+        // successful set
+        $expectedResult = array_merge($multi, $newpair);
+        $this->redis->setMultiple($multi);
+        $this->assertTrue($this->redis->setMultiplePreserve($newpair));
+        $this->assertEquals(
+            array_values($expectedResult), 
+            $this->redis->getMultiple(array_keys($expectedResult))
+        );
+
+        $this->redis->flushDatabase();
+
+        // unsuccessful set
+        $expectedResult = array_merge($multi, array('hogehoge' => null));
+        $this->redis->setMultiple($multi);
+        $this->assertFalse($this->redis->setMultiplePreserve(array_merge($newpair, $hijacked)));
+        $this->assertEquals(
+            array_values($expectedResult), 
+            $this->redis->getMultiple(array_keys($expectedResult))
+        );
+    }
+
+    function testGetSet() {
+        $this->assertNull($this->redis->getSet('foo', 'bar'));
+        $this->assertEquals('bar', $this->redis->getSet('foo', 'barbar'));
+        $this->assertEquals('barbar', $this->redis->getSet('foo', 'baz'));
+    }
+
+    function testIncrementAndIncrementBy() {
+        // test subsequent increment commands
+        $this->assertEquals(1, $this->redis->increment('foo'));
+        $this->assertEquals(2, $this->redis->increment('foo'));
+
+        // test subsequent incrementBy commands
+        $this->assertEquals(22, $this->redis->incrementBy('foo', 20));
+        $this->assertEquals(10, $this->redis->incrementBy('foo', -12));
+        $this->assertEquals(-100, $this->redis->incrementBy('foo', -110));
+    }
+
+    function testDecrementAndDecrementBy() {
+        // test subsequent decrement commands
+        $this->assertEquals(-1, $this->redis->decrement('foo'));
+        $this->assertEquals(-2, $this->redis->decrement('foo'));
+
+        // test subsequent decrementBy commands
+        $this->assertEquals(-22, $this->redis->decrementBy('foo', 20));
+        $this->assertEquals(-10, $this->redis->decrementBy('foo', -12));
+        $this->assertEquals(100, $this->redis->decrementBy('foo', -110));
+    }
+
+    function testDelete() {
+        $this->redis->set('foo', 'bar');
+        $this->assertTrue($this->redis->delete('foo'));
+        $this->assertFalse($this->redis->exists('foo'));
+        $this->assertFalse($this->redis->delete('foo'));
+    }
+
+    function testType() {
+        $this->assertEquals('none', $this->redis->type('fooDoesNotExist'));
+
+        $this->redis->set('fooString', 'bar');
+        $this->assertEquals('string', $this->redis->type('fooString'));
+
+        $this->redis->pushTail('fooList', 'bar');
+        $this->assertEquals('list', $this->redis->type('fooList'));
+
+        $this->redis->setAdd('fooSet', 'bar');
+        $this->assertEquals('set', $this->redis->type('fooSet'));
+
+        $this->redis->zsetAdd('fooZSet', 'bar', 0);
+        $this->assertEquals('zset', $this->redis->type('fooZSet'));
+    }
+
+
+    /* commands operating on the key space */
+
+    function testKeys() {
+        $keyValsNs     = RC::getNamespacedKeyValueArray();
+        $keyValsOthers = array('aaa' => 1, 'aba' => 2, 'aca' => 3);
+        $allKeyVals    = array_merge($keyValsNs, $keyValsOthers);
+
+        $this->redis->setMultiple($allKeyVals);
+
+        $this->assertEquals(array(), $this->redis->keys('nokeys:*'));
+
+        $keysFromRedis = $this->redis->keys('metavar:*');
+        $this->assertEquals(array(), array_diff(array_keys($keyValsNs), $keysFromRedis));
+
+        $keysFromRedis = $this->redis->keys('*');
+        $this->assertEquals(array(), array_diff(array_keys($allKeyVals), $keysFromRedis));
+
+        $keysFromRedis = $this->redis->keys('a?a');
+        $this->assertEquals(array(), array_diff(array_keys($keyValsOthers), $keysFromRedis));
+    }
+
+    function testRandomKey() {
+        $keyvals = RC::getKeyValueArray();
+
+        $this->assertNull($this->redis->randomKey());
+
+        $this->redis->setMultiple($keyvals);
+        $this->assertTrue(in_array($this->redis->randomKey(), array_keys($keyvals)));
+    }
+
+    function testRename() {
+        $this->redis->setMultiple(array('foo' => 'bar', 'foofoo' => 'barbar'));
+
+        // rename existing keys
+        $this->assertTrue($this->redis->rename('foo', 'foofoo'));
+        $this->assertFalse($this->redis->exists('foo'));
+        $this->assertEquals('bar', $this->redis->get('foofoo'));
+
+        // should throw an excepion then trying to rename non-existing keys
+        RC::testForServerException($this, RC::EXCEPTION_NO_SUCH_KEY, function($test) {
+            $test->redis->rename('hoge', 'hogehoge');
+        });
+    }
+
+    function testRenamePreserve() {
+        $this->redis->setMultiple(array('foo' => 'bar', 'hoge' => 'piyo', 'hogehoge' => 'piyopiyo'));
+
+        $this->assertTrue($this->redis->renamePreserve('foo', 'foofoo'));
+        $this->assertFalse($this->redis->exists('foo'));
+        $this->assertEquals('bar', $this->redis->get('foofoo'));
+
+        $this->assertFalse($this->redis->renamePreserve('hoge', 'hogehoge'));
+        $this->assertTrue($this->redis->exists('hoge'));
+
+        // should throw an excepion then trying to rename non-existing keys
+        RC::testForServerException($this, RC::EXCEPTION_NO_SUCH_KEY, function($test) {
+            $test->redis->renamePreserve('fuga', 'baz');
+        });
+    }
+
+    function testExpirationAndTTL() {
+        $this->redis->set('foo', 'bar');
+
+        // check for key expiration
+        $this->assertTrue($this->redis->expire('foo', 1));
+        $this->assertEquals(1, $this->redis->ttl('foo'));
+        $this->assertTrue($this->redis->exists('foo'));
+        sleep(2);
+        $this->assertFalse($this->redis->exists('foo'));
+        $this->assertEquals(-1, $this->redis->ttl('foo'));
+
+        // check for consistent TTL values
+        $this->redis->set('foo', 'bar');
+        $this->assertTrue($this->redis->expire('foo', 100));
+        sleep(3);
+        $this->assertEquals(97, $this->redis->ttl('foo'));
+
+        // delete key on negative TTL
+        $this->redis->set('foo', 'bar');
+        $this->assertTrue($this->redis->expire('foo', -100));
+        $this->assertFalse($this->redis->exists('foo'));
+        $this->assertEquals(-1, $this->redis->ttl('foo'));
+    }
+
+    function testDatabaseSize() {
+        // TODO: is this really OK?
+        $this->assertEquals(0, $this->redis->databaseSize());
+        $this->redis->setMultiple(RC::getKeyValueArray());
+        $this->assertGreaterThan(0, $this->redis->databaseSize());
+    }
+
+
+    /* commands operating on lists */
+
+    function testPushTail() {
+        $this->assertTrue($this->redis->pushTail('metavars', 'foo'));
+        $this->assertTrue($this->redis->exists('metavars'));
+        $this->assertTrue($this->redis->pushTail('metavars', 'hoge'));
+
+        // should throw an exception when trying to do a RPUSH on non-list types
+        // should throw an exception when trying to do a LPUSH on non-list types
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->pushTail('foo', 'bar');
+        });
+    }
+
+    function testPushHead() {
+        $this->assertTrue($this->redis->pushHead('metavars', 'foo'));
+        $this->assertTrue($this->redis->exists('metavars'));
+        $this->assertTrue($this->redis->pushHead('metavars', 'hoge'));
+
+        // should throw an exception when trying to do a LPUSH on non-list types
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->pushHead('foo', 'bar');
+        });
+    }
+
+    function testListLength() {
+        $this->assertTrue($this->redis->pushTail('metavars', 'foo'));
+        $this->assertTrue($this->redis->pushTail('metavars', 'hoge'));
+        $this->assertEquals(2, $this->redis->listLength('metavars'));
+
+        $this->assertEquals(0, $this->redis->listLength('doesnotexist'));
+
+        // should throw an exception when trying to do a LLEN on non-list types
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->listLength('foo');
+        });
+    }
+
+    function testListRange() {
+        $numbers = RC::pushTailAndReturn($this->redis, 'numbers', RC::getArrayOfNumbers());
+
+        $this->assertEquals(
+            array_slice($numbers, 0, 4),
+            $this->redis->listRange('numbers', 0, 3)
+        );
+        $this->assertEquals(
+            array_slice($numbers, 4, 5),
+            $this->redis->listRange('numbers', 4, 8)
+        );
+        $this->assertEquals(
+            array_slice($numbers, 0, 1),
+            $this->redis->listRange('numbers', 0, 0)
+        );
+        $this->assertEquals(
+            array(),
+            $this->redis->listRange('numbers', 1, 0)
+        );
+        $this->assertEquals(
+            $numbers,
+            $this->redis->listRange('numbers', 0, -1)
+        );
+        $this->assertEquals(
+            array(5),
+            $this->redis->listRange('numbers', 5, -5)
+        );
+        $this->assertEquals(
+            array(),
+            $this->redis->listRange('numbers', 7, -5)
+        );
+        $this->assertEquals(
+            array_slice($numbers, -5, -1),
+            $this->redis->listRange('numbers', -5, -2)
+        );
+        $this->assertEquals(
+            $numbers,
+            $this->redis->listRange('numbers', -100, 100)
+        );
+
+        $this->assertNull($this->redis->listRange('keyDoesNotExist', 0, 1));
+
+        // should throw an exception when trying to do a LRANGE on non-list types
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->listRange('foo', 0, -1);
+        });
+    }
+
+    function testListTrim() {
+        $numbers = RC::pushTailAndReturn($this->redis, 'numbers', RC::getArrayOfNumbers());
+        $this->assertTrue($this->redis->listTrim('numbers', 0, 2));
+        $this->assertEquals(
+            array_slice($numbers, 0, 3), 
+            $this->redis->listRange('numbers', 0, -1)
+        );
+
+        $numbers = RC::pushTailAndReturn($this->redis, 'numbers', RC::getArrayOfNumbers(), RC::WIPE_OUT);
+        $this->assertTrue($this->redis->listTrim('numbers', 5, 9));
+        $this->assertEquals(
+            array_slice($numbers, 5, 5), 
+            $this->redis->listRange('numbers', 0, -1)
+        );
+
+        $numbers = RC::pushTailAndReturn($this->redis, 'numbers', RC::getArrayOfNumbers(), RC::WIPE_OUT);
+        $this->assertTrue($this->redis->listTrim('numbers', 0, -6));
+        $this->assertEquals(
+            array_slice($numbers, 0, -5), 
+            $this->redis->listRange('numbers', 0, -1)
+        );
+
+        $numbers = RC::pushTailAndReturn($this->redis, 'numbers', RC::getArrayOfNumbers(), RC::WIPE_OUT);
+        $this->assertTrue($this->redis->listTrim('numbers', -5, -3));
+        $this->assertEquals(
+            array_slice($numbers, 5, 3), 
+            $this->redis->listRange('numbers', 0, -1)
+        );
+
+        $numbers = RC::pushTailAndReturn($this->redis, 'numbers', RC::getArrayOfNumbers(), RC::WIPE_OUT);
+        $this->assertTrue($this->redis->listTrim('numbers', -100, 100));
+        $this->assertEquals(
+            $numbers, 
+            $this->redis->listRange('numbers', 0, -1)
+        );
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->listTrim('foo', 0, 1);
+        });
+    }
+
+    function testListIndex() {
+        $numbers = RC::pushTailAndReturn($this->redis, 'numbers', RC::getArrayOfNumbers());
+
+        $this->assertEquals(0, $this->redis->listIndex('numbers', 0));
+        $this->assertEquals(5, $this->redis->listIndex('numbers', 5));
+        $this->assertEquals(9, $this->redis->listIndex('numbers', 9));
+        $this->assertNull($this->redis->listIndex('numbers', 100));
+
+        $this->assertEquals(0, $this->redis->listIndex('numbers', -0));
+        $this->assertEquals(9, $this->redis->listIndex('numbers', -1));
+        $this->assertEquals(7, $this->redis->listIndex('numbers', -3));
+        $this->assertNull($this->redis->listIndex('numbers', -100));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->listIndex('foo', 0);
+        });
+    }
+
+    function testListSet() {
+        $numbers = RC::pushTailAndReturn($this->redis, 'numbers', RC::getArrayOfNumbers());
+
+        $this->assertTrue($this->redis->listSet('numbers', 5, -5));
+        $this->assertEquals(-5, $this->redis->listIndex('numbers', 5));
+
+        RC::testForServerException($this, RC::EXCEPTION_OUT_OF_RANGE, function($test) {
+            $test->redis->listSet('numbers', 99, 99);
+        });
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->listSet('foo', 0, 0);
+        });
+    }
+
+    function testListRemove() {
+        $mixed = array(0, '_', 2, '_', 4, '_', 6, '_');
+
+        RC::pushTailAndReturn($this->redis, 'mixed', $mixed);
+        $this->assertEquals(2, $this->redis->listRemove('mixed', 2, '_'));
+        $this->assertEquals(array(0, 2, 4, '_', 6, '_'), $this->redis->listRange('mixed', 0, -1));
+
+        RC::pushTailAndReturn($this->redis, 'mixed', $mixed, RC::WIPE_OUT);
+        $this->assertEquals(4, $this->redis->listRemove('mixed', 0, '_'));
+        $this->assertEquals(array(0, 2, 4, 6), $this->redis->listRange('mixed', 0, -1));
+
+        RC::pushTailAndReturn($this->redis, 'mixed', $mixed, RC::WIPE_OUT);
+        $this->assertEquals(2, $this->redis->listRemove('mixed', -2, '_'));
+        $this->assertEquals(array(0, '_', 2, '_', 4, 6), $this->redis->listRange('mixed', 0, -1));
+
+        RC::pushTailAndReturn($this->redis, 'mixed', $mixed, RC::WIPE_OUT);
+        $this->assertEquals(0, $this->redis->listRemove('mixed', 2, '|'));
+        $this->assertEquals($mixed, $this->redis->listRange('mixed', 0, -1));
+
+        $this->assertEquals(0, $this->redis->listRemove('listDoesNotExist', 2, '_'));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->listRemove('foo', 0, 0);
+        });
+    }
+
+    function testListPopFirst() {
+        $numbers = RC::pushTailAndReturn($this->redis, 'numbers', array(0, 1, 2, 3, 4));
+
+        $this->assertEquals(0, $this->redis->popFirst('numbers'));
+        $this->assertEquals(1, $this->redis->popFirst('numbers'));
+        $this->assertEquals(2, $this->redis->popFirst('numbers'));
+
+        $this->assertEquals(array(3, 4), $this->redis->listRange('numbers', 0, -1));
+
+        $this->redis->popFirst('numbers');
+        $this->redis->popFirst('numbers');
+        $this->assertNull($this->redis->popFirst('numbers'));
+
+        $this->assertNull($this->redis->popFirst('numbers'));
+
+        $this->assertNull($this->redis->popFirst('listDoesNotExist'));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->popFirst('foo');
+        });
+    }
+
+    function testListPopLast() {
+        $numbers = RC::pushTailAndReturn($this->redis, 'numbers', array(0, 1, 2, 3, 4));
+
+        $this->assertEquals(4, $this->redis->popLast('numbers'));
+        $this->assertEquals(3, $this->redis->popLast('numbers'));
+        $this->assertEquals(2, $this->redis->popLast('numbers'));
+
+        $this->assertEquals(array(0, 1), $this->redis->listRange('numbers', 0, -1));
+
+        $this->redis->popLast('numbers');
+        $this->redis->popLast('numbers');
+        $this->assertNull($this->redis->popLast('numbers'));
+
+        $this->assertNull($this->redis->popLast('numbers'));
+
+        $this->assertNull($this->redis->popLast('listDoesNotExist'));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->popLast('foo');
+        });
+    }
+
+
+    /* commands operating on sets */
+
+    function testSetAdd() {
+        $this->assertTrue($this->redis->setAdd('set', 0));
+        $this->assertTrue($this->redis->setAdd('set', 1));
+        $this->assertFalse($this->redis->setAdd('set', 0));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->setAdd('foo', 0);
+        });
+    }
+
+    function testSetRemove() {
+        $set = RC::setAddAndReturn($this->redis, 'set', array(0, 1, 2, 3, 4));
+
+        $this->assertTrue($this->redis->setRemove('set', 0));
+        $this->assertTrue($this->redis->setRemove('set', 4));
+        $this->assertFalse($this->redis->setRemove('set', 10));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->setRemove('foo', 0);
+        });
+    }
+
+    function testSetPop() {
+        $set = RC::setAddAndReturn($this->redis, 'set', array(0, 1, 2, 3, 4));
+
+        $this->assertTrue(in_array($this->redis->setPop('set'), $set));
+
+        $this->assertNull($this->redis->setPop('setDoesNotExist'));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->setPop('foo');
+        });
+    }
+
+    function testSetMove() {
+        $setA = RC::setAddAndReturn($this->redis, 'setA', array(0, 1, 2, 3, 4, 5));
+        $setB = RC::setAddAndReturn($this->redis, 'setB', array(5, 6, 7, 8, 9, 10));
+
+        $this->assertTrue($this->redis->setMove('setA', 'setB', 0));
+        $this->assertFalse($this->redis->setRemove('setA', 0));
+        $this->assertTrue($this->redis->setRemove('setB', 0));
+
+        $this->assertTrue($this->redis->setMove('setA', 'setB', 5));
+        $this->assertFalse($this->redis->setMove('setA', 'setB', 100));
+
+        // wrong type
+        $this->redis->set('foo', 'bar');
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setMove('foo', 'setB', 5);
+        });
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setMove('setA', 'foo', 5);
+        });
+    }
+
+    function testSetCardinality() {
+        RC::setAddAndReturn($this->redis, 'setA', array(0, 1, 2, 3, 4, 5));
+
+        $this->assertEquals(6, $this->redis->setCardinality('setA'));
+
+        // empty set
+        $this->redis->setAdd('setB', 0);
+        $this->redis->setPop('setB');
+        $this->assertEquals(0, $this->redis->setCardinality('setB'));
+
+        // non-existing set
+        $this->assertEquals(0, $this->redis->setCardinality('setDoesNotExist'));
+
+        // wrong type
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->setCardinality('foo');
+        });
+    }
+
+    function testSetIsMember() {
+        RC::setAddAndReturn($this->redis, 'set', array(0, 1, 2, 3, 4, 5));
+
+        $this->assertTrue($this->redis->setIsMember('set', 3));
+        $this->assertFalse($this->redis->setIsMember('set', 100));
+
+        $this->assertFalse($this->redis->setIsMember('setDoesNotExist', 0));
+
+        // wrong type
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->setIsMember('foo', 0);
+        });
+    }
+
+    function testSetMembers() {
+        $set = RC::setAddAndReturn($this->redis, 'set', array(0, 1, 2, 3, 4, 5, 6));
+
+        $this->assertTrue(RC::sameValuesInArrays($set, $this->redis->setMembers('set')));
+
+        $this->assertNull($this->redis->setMembers('setDoesNotExist'));
+
+        // wrong type
+        $this->redis->set('foo', 'bar');
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setMembers('foo');
+        });
+    }
+
+    function testSetIntersection() {
+        $setA = RC::setAddAndReturn($this->redis, 'setA', array(0, 1, 2, 3, 4, 5, 6));
+        $setB = RC::setAddAndReturn($this->redis, 'setB', array(1, 3, 4, 6, 9, 10));
+
+        $this->assertTrue(RC::sameValuesInArrays(
+            $setA, 
+            $this->redis->setIntersection('setA')
+        ));
+
+        $this->assertTrue(RC::sameValuesInArrays(
+            array_intersect($setA, $setB), 
+            $this->redis->setIntersection('setA', 'setB')
+        ));
+
+        // TODO: should nil really be considered an empty set?
+        $this->assertNull($this->redis->setIntersection('setA', 'setDoesNotExist'));
+
+        // wrong type
+        $this->redis->set('foo', 'bar');
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setIntersection('foo');
+        });
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setIntersection('setA', 'foo');
+        });
+    }
+
+    function testSetIntersectionStore() {
+        $setA = RC::setAddAndReturn($this->redis, 'setA', array(0, 1, 2, 3, 4, 5, 6));
+        $setB = RC::setAddAndReturn($this->redis, 'setB', array(1, 3, 4, 6, 9, 10));
+
+        $this->assertEquals(count($setA), $this->redis->setIntersectionStore('setC', 'setA'));
+        $this->assertTrue(RC::sameValuesInArrays(
+            $setA, 
+            $this->redis->setMembers('setC')
+        ));
+
+        $this->redis->delete('setC');
+        $this->assertEquals(4, $this->redis->setIntersectionStore('setC', 'setA', 'setB'));
+        $this->assertTrue(RC::sameValuesInArrays(
+            array(1, 3, 4, 6), 
+            $this->redis->setMembers('setC')
+        ));
+
+        $this->redis->delete('setC');
+        $this->assertNull($this->redis->setIntersection('setC', 'setDoesNotExist'));
+        $this->assertFalse($this->redis->exists('setC'));
+
+        // existing keys are replaced by SINTERSTORE
+        $this->redis->set('foo', 'bar');
+        $this->assertEquals(count($setA), $this->redis->setIntersectionStore('foo', 'setA'));
+
+        // wrong type
+        $this->redis->set('foo', 'bar');
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setIntersectionStore('setA', 'foo');
+        });
+    }
+
+    function testSetUnion() {
+        $setA = RC::setAddAndReturn($this->redis, 'setA', array(0, 1, 2, 3, 4, 5, 6));
+        $setB = RC::setAddAndReturn($this->redis, 'setB', array(1, 3, 4, 6, 9, 10));
+
+        $this->assertTrue(RC::sameValuesInArrays(
+            $setA, 
+            $this->redis->setUnion('setA')
+        ));
+
+        $this->assertTrue(RC::sameValuesInArrays(
+            array_union($setA, $setB), 
+            $this->redis->setUnion('setA', 'setB')
+        ));
+
+        $this->assertTrue(RC::sameValuesInArrays(
+            $setA, 
+            $this->redis->setUnion('setA', 'setDoesNotExist')
+        ));
+
+        // wrong type
+        $this->redis->set('foo', 'bar');
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setUnion('foo');
+        });
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setUnion('setA', 'foo');
+        });
+    }
+
+    function testSetUnionStore() {
+        $setA = RC::setAddAndReturn($this->redis, 'setA', array(0, 1, 2, 3, 4, 5, 6));
+        $setB = RC::setAddAndReturn($this->redis, 'setB', array(1, 3, 4, 6, 9, 10));
+
+        $this->assertEquals(count($setA), $this->redis->setUnionStore('setC', 'setA'));
+        $this->assertTrue(RC::sameValuesInArrays(
+            $setA, 
+            $this->redis->setMembers('setC')
+        ));
+
+        $this->redis->delete('setC');
+        $this->assertEquals(9, $this->redis->setUnionStore('setC', 'setA', 'setB'));
+        $this->assertTrue(RC::sameValuesInArrays(
+            array_union($setA, $setB), 
+            $this->redis->setMembers('setC')
+        ));
+
+        // non-existing keys are considered empty sets
+        $this->redis->delete('setC');
+        $this->assertEquals(0, $this->redis->setUnionStore('setC', 'setDoesNotExist'));
+        $this->assertTrue($this->redis->exists('setC'));
+        $this->assertEquals(0, $this->redis->setCardinality('setC'));
+
+        // existing keys are replaced by SUNIONSTORE
+        $this->redis->set('foo', 'bar');
+        $this->assertEquals(count($setA), $this->redis->setUnionStore('foo', 'setA'));
+
+        // wrong type
+        $this->redis->set('foo', 'bar');
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setUnionStore('setA', 'foo');
+        });
+    }
+
+    function testSetDifference() {
+        $setA = RC::setAddAndReturn($this->redis, 'setA', array(0, 1, 2, 3, 4, 5, 6));
+        $setB = RC::setAddAndReturn($this->redis, 'setB', array(1, 3, 4, 6, 9, 10));
+
+        $this->assertTrue(RC::sameValuesInArrays(
+            $setA, 
+            $this->redis->setDifference('setA')
+        ));
+
+        $this->assertTrue(RC::sameValuesInArrays(
+            array_diff($setA, $setB), 
+            $this->redis->setDifference('setA', 'setB')
+        ));
+
+        $this->assertTrue(RC::sameValuesInArrays(
+            $setA, 
+            $this->redis->setDifference('setA', 'setDoesNotExist')
+        ));
+
+        // wrong type
+        $this->redis->set('foo', 'bar');
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setDifference('foo');
+        });
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setDifference('setA', 'foo');
+        });
+    }
+
+    function testSetDifferenceStore() {
+        $setA = RC::setAddAndReturn($this->redis, 'setA', array(0, 1, 2, 3, 4, 5, 6));
+        $setB = RC::setAddAndReturn($this->redis, 'setB', array(1, 3, 4, 6, 9, 10));
+
+        $this->assertEquals(count($setA), $this->redis->setDifferenceStore('setC', 'setA'));
+        $this->assertTrue(RC::sameValuesInArrays(
+            $setA, 
+            $this->redis->setMembers('setC')
+        ));
+
+        $this->redis->delete('setC');
+        $this->assertEquals(3, $this->redis->setDifferenceStore('setC', 'setA', 'setB'));
+        $this->assertTrue(RC::sameValuesInArrays(
+            array_diff($setA, $setB), 
+            $this->redis->setMembers('setC')
+        ));
+
+        // non-existing keys are considered empty sets
+        $this->redis->delete('setC');
+        $this->assertEquals(0, $this->redis->setDifferenceStore('setC', 'setDoesNotExist'));
+        $this->assertTrue($this->redis->exists('setC'));
+        $this->assertEquals(0, $this->redis->setCardinality('setC'));
+
+        // existing keys are replaced by SDIFFSTORE
+        $this->redis->set('foo', 'bar');
+        $this->assertEquals(count($setA), $this->redis->setDifferenceStore('foo', 'setA'));
+
+        // wrong type
+        $this->redis->set('foo', 'bar');
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setDifferenceStore('setA', 'foo');
+        });
+    }
+
+    function testRandomMember() {
+        $set = RC::setAddAndReturn($this->redis, 'set', array(0, 1, 2, 3, 4, 5, 6));
+
+        $this->assertTrue(in_array($this->redis->setRandomMember('set'), $set));
+
+        $this->assertNull($this->redis->setRandomMember('setDoesNotExist'));
+
+        // wrong type
+        $this->redis->set('foo', 'bar');
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->setRandomMember('foo');
+        });
+    }
+
+
+    /* commands operating on sorted sets */
+
+    function testZsetAdd() {
+        $this->assertTrue($this->redis->zsetAdd('zset', 0, 'a'));
+        $this->assertTrue($this->redis->zsetAdd('zset', 1, 'b'));
+
+        $this->assertTrue($this->redis->zsetAdd('zset', -1, 'c'));
+
+        // TODO: floats?
+        //$this->assertTrue($this->redis->zsetAdd('zset', -1.0, 'c'));
+        //$this->assertTrue($this->redis->zsetAdd('zset', -1.0, 'c'));
+
+        $this->assertFalse($this->redis->zsetAdd('zset', 2, 'b'));
+        $this->assertFalse($this->redis->zsetAdd('zset', -2, 'b'));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->zsetAdd('foo', 0, 'a');
+        });
+    }
+
+    function testZsetRemove() {
+        RC::zsetAddAndReturn($this->redis, 'zset', RC::getZSetArray());
+        
+        $this->assertTrue($this->redis->zsetRemove('zset', 'a'));
+        $this->assertFalse($this->redis->zsetRemove('zset', 'x'));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->zsetRemove('foo', 'bar');
+        });
+    }
+
+    function testZsetRange() {
+        $zset = RC::zsetAddAndReturn($this->redis, 'zset', RC::getZSetArray());
+
+        $this->assertEquals(
+            array_slice(array_keys($zset), 0, 4), 
+            $this->redis->zsetRange('zset', 0, 3)
+        );
+
+        $this->assertEquals(
+            array_slice(array_keys($zset), 0, 1), 
+            $this->redis->zsetRange('zset', 0, 0)
+        );
+
+        $this->assertEquals(
+            array(), 
+            $this->redis->zsetRange('zset', 1, 0)
+        );
+
+        $this->assertEquals(
+            array_values(array_keys($zset)), 
+            $this->redis->zsetRange('zset', 0, -1)
+        );
+
+        $this->assertEquals(
+            array_slice(array_keys($zset), 3, 1), 
+            $this->redis->zsetRange('zset', 3, -3)
+        );
+
+        $this->assertEquals(
+            array(), 
+            $this->redis->zsetRange('zset', 5, -3)
+        );
+
+        $this->assertEquals(
+            array_slice(array_keys($zset), -5, -1), 
+            $this->redis->zsetRange('zset', -5, -2)
+        );
+
+        $this->assertEquals(
+            array_values(array_keys($zset)), 
+            $this->redis->zsetRange('zset', -100, 100)
+        );
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->zsetRange('foo', 0, -1);
+        });
+    }
+
+    function testZsetReverseRange() {
+        $zset = RC::zsetAddAndReturn($this->redis, 'zset', RC::getZSetArray());
+
+        $this->assertEquals(
+            array_slice(array_reverse(array_keys($zset)), 0, 4), 
+            $this->redis->zsetReverseRange('zset', 0, 3)
+        );
+
+        $this->assertEquals(
+            array_slice(array_reverse(array_keys($zset)), 0, 1), 
+            $this->redis->zsetReverseRange('zset', 0, 0)
+        );
+
+        $this->assertEquals(
+            array(), 
+            $this->redis->zsetReverseRange('zset', 1, 0)
+        );
+
+        $this->assertEquals(
+            array_values(array_reverse(array_keys($zset))), 
+            $this->redis->zsetReverseRange('zset', 0, -1)
+        );
+
+        $this->assertEquals(
+            array_slice(array_reverse(array_keys($zset)), 3, 1), 
+            $this->redis->zsetReverseRange('zset', 3, -3)
+        );
+
+        $this->assertEquals(
+            array(), 
+            $this->redis->zsetReverseRange('zset', 5, -3)
+        );
+
+        $this->assertEquals(
+            array_slice(array_reverse(array_keys($zset)), -5, -1), 
+            $this->redis->zsetReverseRange('zset', -5, -2)
+        );
+
+        $this->assertEquals(
+            array_values(array_reverse(array_keys($zset))), 
+            $this->redis->zsetReverseRange('zset', -100, 100)
+        );
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->zsetReverseRange('foo', 0, -1);
+        });
+    }
+
+    function testZsetRangeByScore() {
+        $zset = RC::zsetAddAndReturn($this->redis, 'zset', RC::getZSetArray());
+
+        $this->assertEquals(
+            array('a'), 
+            $this->redis->zsetRangeByScore('zset', -10, -10)
+        );
+
+        $this->assertEquals(
+            array('c', 'd', 'e', 'f'), 
+            $this->redis->zsetRangeByScore('zset', 10, 30)
+        );
+
+        $this->assertEquals(
+            array('d', 'e'), 
+            $this->redis->zsetRangeByScore('zset', 20, 20)
+        );
+
+        $this->assertEquals(
+            array(), 
+            $this->redis->zsetRangeByScore('zset', 30, 0)
+        );
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->zsetRangeByScore('foo', 0, 0);
+        });
+    }
+
+    function testZsetCardinality() {
+        $zset = RC::zsetAddAndReturn($this->redis, 'zset', RC::getZSetArray());
+
+        $this->assertEquals(count($zset), $this->redis->zsetCardinality('zset'));
+        
+        $this->redis->zsetRemove('zset', 'a');
+        $this->assertEquals(count($zset) - 1, $this->redis->zsetCardinality('zset'));
+
+        // empty zset
+        $this->redis->zsetAdd('zsetB', 0, 'a');
+        $this->redis->zsetRemove('zsetB', 'a');
+        $this->assertEquals(0, $this->redis->zsetCardinality('setB'));
+
+        // non-existing zset
+        $this->assertEquals(0, $this->redis->zsetCardinality('zsetDoesNotExist'));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->zsetCardinality('foo');
+        });
+    }
+
+    function testZsetScore() {
+        $zset = RC::zsetAddAndReturn($this->redis, 'zset', RC::getZSetArray());
+
+        $this->assertEquals(-10, $this->redis->zsetScore('zset', 'a'));
+        $this->assertEquals(10, $this->redis->zsetScore('zset', 'c'));
+        $this->assertEquals(20, $this->redis->zsetScore('zset', 'e'));
+
+        $this->assertNull($this->redis->zsetScore('zset', 'x'));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->zsetScore('foo', 'bar');
+        });
+    }
+
+    function testZsetRemoveRangeByScore() {
+        $zset = RC::zsetAddAndReturn($this->redis, 'zset', RC::getZSetArray());
+
+        $this->assertEquals(2, $this->redis->zsetRemoveRangeByScore('zset', -10, 0));
+        $this->assertEquals(
+            array('c', 'd', 'e', 'f'), 
+            $this->redis->zsetRange('zset', 0, -1)
+        );
+
+        $this->assertEquals(1, $this->redis->zsetRemoveRangeByScore('zset', 10, 10));
+        $this->assertEquals(
+            array('d', 'e', 'f'), 
+            $this->redis->zsetRange('zset', 0, -1)
+        );
+
+        $this->assertEquals(0, $this->redis->zsetRemoveRangeByScore('zset', 100, 100));
+
+        $this->assertEquals(3, $this->redis->zsetRemoveRangeByScore('zset', 0, 100));
+        $this->assertEquals(0, $this->redis->zsetRemoveRangeByScore('zset', 0, 100));
+
+        $this->assertEquals(0, $this->redis->zsetRemoveRangeByScore('zsetDoesNotExist', 0, 100));
+
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->zsetRemoveRangeByScore('foo', 0, 0);
+        });
+    }
+
+
+    /* multiple databases handling commands */
+
+    function testSelectDatabase() {
+        $this->assertTrue($this->redis->selectDatabase(0));
+        $this->assertTrue($this->redis->selectDatabase(RC::DEFAULT_DATABASE));
+
+        RC::testForServerException($this, RC::EXCEPTION_INVALID_DB_IDX, function($test) {
+            $test->redis->selectDatabase(32);
+        });
+
+        RC::testForServerException($this, RC::EXCEPTION_INVALID_DB_IDX, function($test) {
+            $test->redis->selectDatabase(-1);
+        });
+    }
+
+    function testMove() {
+        // TODO: This test sucks big time. Period.
+        $otherDb = 5;
+        $this->redis->set('foo', 'bar');
+
+        $this->redis->selectDatabase($otherDb);
+        $this->redis->flushDatabase();
+        $this->redis->selectDatabase(RC::DEFAULT_DATABASE);
+
+        $this->assertTrue($this->redis->move('foo', $otherDb));
+        $this->assertFalse($this->redis->move('foo', $otherDb));
+        $this->assertFalse($this->redis->move('keyDoesNotExist', $otherDb));
+
+        $this->redis->set('hoge', 'piyo');
+        // TODO: shouldn't Redis send an EXCEPTION_INVALID_DB_IDX instead of EXCEPTION_OUT_OF_RANGE?
+        RC::testForServerException($this, RC::EXCEPTION_OUT_OF_RANGE, function($test) {
+            $test->redis->move('hoge', 32);
+        });
+    }
+
+    function testFlushDatabase() {
+        $this->assertTrue($this->redis->flushDatabase());
+    }
+
+    function testFlushDatabases() {
+        $this->assertTrue($this->redis->flushDatabases());
+    }
+
+
+    /* sorting */
+
+    function testSort() {
+        $unorderedList = RC::pushTailAndReturn($this->redis, 'unordered', array(2, 100, 3, 1, 30, 10));
+
+        // without parameters
+        $this->assertEquals(array(1, 2, 3, 10, 30, 100), $this->redis->sort('unordered'));
+
+        // with parameter ASC/DESC
+        $this->assertEquals(
+            array(100, 30, 10, 3, 2, 1), 
+            $this->redis->sort('unordered', array(
+                'sort' => 'desc'
+            ))
+        );
+
+        // with parameter LIMIT
+        $this->assertEquals(
+            array(1, 2, 3), 
+            $this->redis->sort('unordered', array(
+                'limit' => array(0, 3)
+            ))
+        );
+        $this->assertEquals(
+            array(10, 30), 
+            $this->redis->sort('unordered', array(
+                'limit' => array(3, 2)
+            ))
+        );
+
+        // with parameter ALPHA
+        $this->assertEquals(
+            array(1, 10, 100, 2, 3, 30), 
+            $this->redis->sort('unordered', array(
+                'alpha' => true
+            ))
+        );
+
+        // with combined parameters
+        $this->assertEquals(
+            array(30, 10, 3, 2), 
+            $this->redis->sort('unordered', array(
+                'alpha' => false, 
+                'sort'  => 'desc', 
+                'limit' => array(1, 4)
+            ))
+        );
+
+        // with parameter ALPHA
+        $this->assertEquals(
+            array(1, 10, 100, 2, 3, 30), 
+            $this->redis->sort('unordered', array(
+                'alpha' => true
+            ))
+        );
+
+        // with parameter STORE
+        $this->assertEquals(
+            count($unorderedList), 
+            $this->redis->sort('unordered', array(
+                'store' => 'ordered'
+            ))
+        );
+        $this->assertEquals(array(1, 2, 3, 10, 30, 100),  $this->redis->listRange('ordered', 0, -1));
+
+        // wront type
+        RC::testForServerException($this, RC::EXCEPTION_WRONG_TYPE, function($test) {
+            $test->redis->set('foo', 'bar');
+            $test->redis->sort('foo');
+        });
+    }
+
+    /* remote server control commands */
+
+    function testInfo() {
+        $serverInfo = $this->redis->info();
+
+        $this->assertType('array', $serverInfo);
+        $this->assertNotNull($serverInfo['redis_version']);
+        $this->assertGreaterThan(0, $serverInfo['uptime_in_seconds']);
+        $this->assertGreaterThan(0, $serverInfo['total_connections_received']);
+    }
+
+    function testSlaveOf() {
+        $masterHost = 'www.google.com';
+        $masterPort = 80;
+
+        $this->assertTrue($this->redis->slaveOf($masterHost, $masterPort));
+        $serverInfo = $this->redis->info();
+        $this->assertEquals('slave', $serverInfo['role']);
+        $this->assertEquals($masterHost, $serverInfo['master_host']);
+        $this->assertEquals($masterPort, $serverInfo['master_port']);
+
+        // slave of NO ONE, the implicit way
+        $this->assertTrue($this->redis->slaveOf());
+        $serverInfo = $this->redis->info();
+        $this->assertEquals('master', $serverInfo['role']);
+
+        // slave of NO ONE, the explicit way
+        $this->assertTrue($this->redis->slaveOf('NO ONE'));
+    }
+
+
+    /* persistence control commands */
+
+    function testSave() {
+        $this->assertTrue($this->redis->save());
+    }
+
+    function testBackgroundSave() {
+        $this->assertTrue($this->redis->backgroundSave());
+    }
+
+    function testLastSave() {
+        $this->assertGreaterThan(0, $this->redis->lastSave());
+    }
+
+    function testShutdown() {
+        // TODO: seriously, we even test shutdown?
+        /*
+        $this->assertNull($this->redis->shutdown());
+        sleep(1);
+        $this->assertFalse($this->redis->isConnected());
+        */
+    }
+}
+?>