From 34a616cd95cdfc48dfbec2d23025bcf2e271a75a Mon Sep 17 00:00:00 2001 From: Daniele Alessandri Date: Sat, 7 Nov 2009 13:10:51 +0100 Subject: [PATCH] ... and here finally comes Predis! --- LICENSE | 22 + README.markdown | 116 +++ TODO | 21 + examples/CommandPipeline.php | 39 + examples/MultipleSetAndGet.php | 30 + examples/SharedConfigurations.php | 7 + examples/SimpleSetAndGet.php | 17 + lib/Predis.php | 1106 ++++++++++++++++++++++++++ test/PredisShared.php | 124 +++ test/RedisCommandsTest.php | 1213 +++++++++++++++++++++++++++++ 10 files changed, 2695 insertions(+) create mode 100644 LICENSE create mode 100644 README.markdown create mode 100644 TODO create mode 100644 examples/CommandPipeline.php create mode 100644 examples/MultipleSetAndGet.php create mode 100644 examples/SharedConfigurations.php create mode 100644 examples/SimpleSetAndGet.php create mode 100644 lib/Predis.php create mode 100644 test/PredisShared.php create mode 100644 test/RedisCommandsTest.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..9684f819 --- /dev/null +++ b/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. \ No newline at end of file diff --git a/README.markdown b/README.markdown new file mode 100644 index 00000000..15b1291d --- /dev/null +++ b/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). diff --git a/TODO b/TODO new file mode 100644 index 00000000..a8123b6a --- /dev/null +++ b/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. + diff --git a/examples/CommandPipeline.php b/examples/CommandPipeline.php new file mode 100644 index 00000000..7cc84fb0 --- /dev/null +++ b/examples/CommandPipeline.php @@ -0,0 +1,39 @@ +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 + ) + +) +*/ +?> \ No newline at end of file diff --git a/examples/MultipleSetAndGet.php b/examples/MultipleSetAndGet.php new file mode 100644 index 00000000..6932ec5e --- /dev/null +++ b/examples/MultipleSetAndGet.php @@ -0,0 +1,30 @@ + '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 +) +*/ +?> \ No newline at end of file diff --git a/examples/SharedConfigurations.php b/examples/SharedConfigurations.php new file mode 100644 index 00000000..6e41b60f --- /dev/null +++ b/examples/SharedConfigurations.php @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/examples/SimpleSetAndGet.php b/examples/SimpleSetAndGet.php new file mode 100644 index 00000000..b8a3c648 --- /dev/null +++ b/examples/SimpleSetAndGet.php @@ -0,0 +1,17 @@ +select(REDIS_DB); + +$redis->set('library', 'predis'); +$retval = $redis->get('library'); + +print_r($retval); + +/* OUTPUT +predis +*/ +?> \ No newline at end of file diff --git a/lib/Predis.php b/lib/Predis.php new file mode 100644 index 00000000..0981fc45 --- /dev/null +++ b/lib/Predis.php @@ -0,0 +1,1106 @@ +_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; + } +} +?> diff --git a/test/PredisShared.php b/test/PredisShared.php new file mode 100644 index 00000000..8185e5a5 --- /dev/null +++ b/test/PredisShared.php @@ -0,0 +1,124 @@ +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; + } +} +?> diff --git a/test/RedisCommandsTest.php b/test/RedisCommandsTest.php new file mode 100644 index 00000000..41fbd70f --- /dev/null +++ b/test/RedisCommandsTest.php @@ -0,0 +1,1213 @@ +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()); + */ + } +} +?>