diff --git a/src/ClientContextInterface.php b/src/ClientContextInterface.php index 06df7920..2b5ba40d 100644 --- a/src/ClientContextInterface.php +++ b/src/ClientContextInterface.php @@ -160,6 +160,7 @@ use Predis\Command\CommandInterface; * @method $this geohash($key, array $members) * @method $this geopos($key, array $members) * @method $this geodist($key, $member1, $member2, $unit = null) + * @method $this georadius($key, $longitude, $latitude, $radius, $unit, array $options = null) * * @author Daniele Alessandri */ diff --git a/src/ClientInterface.php b/src/ClientInterface.php index bf136598..3db635da 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -168,6 +168,7 @@ use Predis\Profile\ProfileInterface; * @method array geohash($key, array $members) * @method array geopos($key, array $members) * @method string geodist($key, $member1, $member2, $unit = null) + * @method array georadius($key, $longitude, $latitude, $radius, $unit, array $options = null) * * @author Daniele Alessandri */ diff --git a/src/Cluster/ClusterStrategy.php b/src/Cluster/ClusterStrategy.php index eec40762..4f826e52 100644 --- a/src/Cluster/ClusterStrategy.php +++ b/src/Cluster/ClusterStrategy.php @@ -171,6 +171,7 @@ abstract class ClusterStrategy implements StrategyInterface 'GEOHASH' => $getKeyFromFirstArgument, 'GEOPOS' => $getKeyFromFirstArgument, 'GEODIST' => $getKeyFromFirstArgument, + 'GEORADIUS' => array($this, 'getKeyFromGeoradiusCommands'), ); } @@ -300,6 +301,38 @@ abstract class ClusterStrategy implements StrategyInterface } } + /** + * Extracts the key from GEORADIUS command. + * + * @param CommandInterface $command Command instance. + * + * @return string|null + */ + protected function getKeyFromGeoradiusCommands(CommandInterface $command) + { + $arguments = $command->getArguments(); + $argc = count($arguments); + + if ($argc > 5) { + $keys = array($arguments[0]); + + for ($i = 5; $i < $argc; $i++) { + $argument = strtoupper($arguments[$i]); + if ($argument === 'STORE' || $argument === 'STOREDIST') { + $keys[] = $arguments[++$i]; + } + } + + if ($this->checkSameSlotForKeys($keys)) { + return $arguments[0]; + } else { + return null; + } + } + + return $arguments[0]; + } + /** * Extracts the key from ZINTERSTORE and ZUNIONSTORE commands. * diff --git a/src/Command/GeospatialGeoRadius.php b/src/Command/GeospatialGeoRadius.php new file mode 100644 index 00000000..f2052148 --- /dev/null +++ b/src/Command/GeospatialGeoRadius.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Predis\Command; + +/** + * @link http://redis.io/commands/georadius + * + * @author Daniele Alessandri + */ +class GeospatialGeoRadius extends Command +{ + /** + * {@inheritdoc} + */ + public function getId() + { + return 'GEORADIUS'; + } + + /** + * {@inheritdoc} + */ + protected function filterArguments(array $arguments) + { + if ($arguments && is_array(end($arguments))) { + $options = array_change_key_case(array_pop($arguments), CASE_UPPER); + + if (isset($options['WITHCOORD']) && $options['WITHCOORD'] == true) { + $arguments[] = 'WITHCOORD'; + } + + if (isset($options['WITHDIST']) && $options['WITHDIST'] == true) { + $arguments[] = 'WITHDIST'; + } + + if (isset($options['WITHHASH']) && $options['WITHHASH'] == true) { + $arguments[] = 'WITHHASH'; + } + + if (isset($options['COUNT'])) { + $arguments[] = 'COUNT'; + $arguments[] = $options['COUNT']; + } + + if (isset($options['SORT'])) { + $arguments[] = strtoupper($options['SORT']); + } + + if (isset($options['STORE'])) { + $arguments[] = 'STORE'; + $arguments[] = $options['STORE']; + } + + if (isset($options['STOREDIST'])) { + $arguments[] = 'STOREDIST'; + $arguments[] = $options['STOREDIST']; + } + } + + return $arguments; + } +} diff --git a/src/Command/Processor/KeyPrefixProcessor.php b/src/Command/Processor/KeyPrefixProcessor.php index 6ce6cf9f..53c0a0f0 100644 --- a/src/Command/Processor/KeyPrefixProcessor.php +++ b/src/Command/Processor/KeyPrefixProcessor.php @@ -164,6 +164,7 @@ class KeyPrefixProcessor implements ProcessorInterface 'GEOHASH' => 'static::first', 'GEOPOS' => 'static::first', 'GEODIST' => 'static::first', + 'GEORADIUS' => 'static::georadius', ); } @@ -417,4 +418,31 @@ class KeyPrefixProcessor implements ProcessorInterface $command->setRawArguments($arguments); } } + + /** + * Applies the specified prefix to the key of a GEORADIUS command. + * + * @param CommandInterface $command Command instance. + * @param string $prefix Prefix string. + */ + public static function georadius(CommandInterface $command, $prefix) + { + if ($arguments = $command->getArguments()) { + $arguments[0] = "$prefix{$arguments[0]}"; + + if (($count = count($arguments)) > 5) { + for ($i = 5; $i < $count; ++$i) { + switch (strtoupper($arguments[$i])) { + case 'STORE': + case 'STOREDIST': + $arguments[$i] = "$prefix{$arguments[++$i]}"; + break; + + } + } + } + + $command->setRawArguments($arguments); + } + } } diff --git a/src/Profile/RedisVersion320.php b/src/Profile/RedisVersion320.php index 465d7c97..c55d68a5 100644 --- a/src/Profile/RedisVersion320.php +++ b/src/Profile/RedisVersion320.php @@ -274,6 +274,7 @@ class RedisVersion320 extends RedisProfile 'GEOHASH' => 'Predis\Command\GeospatialGeoHash', 'GEOPOS' => 'Predis\Command\GeospatialGeoPos', 'GEODIST' => 'Predis\Command\GeospatialGeoDist', + 'GEORADIUS' => 'Predis\Command\GeospatialGeoRadius', ); } } diff --git a/src/Replication/ReplicationStrategy.php b/src/Replication/ReplicationStrategy.php index 4cde9e72..b4337c37 100644 --- a/src/Replication/ReplicationStrategy.php +++ b/src/Replication/ReplicationStrategy.php @@ -129,6 +129,31 @@ class ReplicationStrategy return true; } + /** + * Checks if a GEORADIUS command is a readable operation by parsing the + * arguments array of the specified commad instance. + * + * @param CommandInterface $command Command instance. + * + * @return bool + */ + protected function isGeoradiusReadOnly(CommandInterface $command) + { + $arguments = $command->getArguments(); + $argc = count($arguments); + + if ($argc > 5) { + for ($i = 5; $i < $argc; $i++) { + $argument = strtoupper($arguments[$i]); + if ($argument === 'STORE' || $argument === 'STOREDIST') { + return false; + } + } + } + + return true; + } + /** * Marks a command as a read-only operation. * @@ -261,6 +286,7 @@ class ReplicationStrategy 'GEOHASH' => true, 'GEOPOS' => true, 'GEODIST' => true, + 'GEORADIUS' => array($this, 'isGeoradiusReadOnly'), ); } } diff --git a/tests/Predis/Cluster/PredisStrategyTest.php b/tests/Predis/Cluster/PredisStrategyTest.php index 7d4a020c..b0108a30 100644 --- a/tests/Predis/Cluster/PredisStrategyTest.php +++ b/tests/Predis/Cluster/PredisStrategyTest.php @@ -152,6 +152,23 @@ class PredisStrategyTest extends PredisTestCase } } + /** + * @group disconnected + */ + public function testKeysForGeoradiusCommand() + { + $strategy = $this->getClusterStrategy(); + $profile = Profile\Factory::getDevelopment(); + + $commandID = 'GEORADIUS'; + + $command = $profile->createCommand($commandID, array('{key}:1', 10, 10, 1, 'km')); + $this->assertNotNull($strategy->getSlot($command), $commandID); + + $command = $profile->createCommand($commandID, array('{key}:1', 10, 10, 1, 'km', 'store', '{key}:2', 'storedist', '{key}:3')); + $this->assertNotNull($strategy->getSlot($command), $commandID); + } + /** * @group disconnected */ @@ -383,6 +400,7 @@ class PredisStrategyTest extends PredisTestCase 'GEOHASH' => 'keys-first', 'GEOPOS' => 'keys-first', 'GEODIST' => 'keys-first', + 'GEORADIUS' => 'keys-georadius', ); if (isset($type)) { diff --git a/tests/Predis/Cluster/RedisStrategyTest.php b/tests/Predis/Cluster/RedisStrategyTest.php index 54cb43cd..f1b856ba 100644 --- a/tests/Predis/Cluster/RedisStrategyTest.php +++ b/tests/Predis/Cluster/RedisStrategyTest.php @@ -165,6 +165,23 @@ class RedisStrategyTest extends PredisTestCase } } + /** + * @group disconnected + */ + public function testKeysForGeoradiusCommand() + { + $strategy = $this->getClusterStrategy(); + $profile = Profile\Factory::getDevelopment(); + + $commandID = 'GEORADIUS'; + + $command = $profile->createCommand($commandID, array('{key}:1', 10, 10, 1, 'km')); + $this->assertNotNull($strategy->getSlot($command), $commandID); + + $command = $profile->createCommand($commandID, array('{key}:1', 10, 10, 1, 'km', 'store', '{key}:2', 'storedist', '{key}:3')); + $this->assertNotNull($strategy->getSlot($command), $commandID); + } + /** * @group disconnected */ @@ -393,6 +410,7 @@ class RedisStrategyTest extends PredisTestCase 'GEOHASH' => 'keys-first', 'GEOPOS' => 'keys-first', 'GEODIST' => 'keys-first', + 'GEORADIUS' => 'keys-georadius', ); if (isset($type)) { diff --git a/tests/Predis/Command/GeospatialGeoRadiusTest.php b/tests/Predis/Command/GeospatialGeoRadiusTest.php new file mode 100644 index 00000000..83d74bed --- /dev/null +++ b/tests/Predis/Command/GeospatialGeoRadiusTest.php @@ -0,0 +1,170 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Predis\Command; + +/** + * @group commands + * @group realm-geospatial + */ +class GeospatialGeoRadiusTest extends PredisCommandTestCase +{ + /** + * {@inheritdoc} + */ + protected function getExpectedCommand() + { + return 'Predis\Command\GeospatialGeoRadius'; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedId() + { + return 'GEORADIUS'; + } + + /** + * @group disconnected + */ + public function testFilterArguments() + { + $arguments = array( + 'Sicily', 15, 37, 200, 'km', + 'WITHCOORD', 'WITHDIST', 'WITHHASH', 'COUNT', 1, 'ASC', 'STORE', 'key:store', 'STOREDIST', 'key:storedist' + ); + + $expected = array( + 'Sicily', 15, 37, 200, 'km', + 'WITHCOORD', 'WITHDIST', 'WITHHASH', 'COUNT', 1, 'ASC', 'STORE', 'key:store', 'STOREDIST', 'key:storedist' + ); + + $command = $this->getCommand(); + $command->setArguments($arguments); + + $this->assertSame($expected, $command->getArguments()); + } + + /** + * @group disconnected + */ + public function testFilterArgumentsWithComplexOptions() + { + $arguments = array( + 'Sicily', 15, 37, 200, 'km', array( + 'store' => 'key:store', + 'storedist' => 'key:storedist', + 'withdist' => true, + 'withcoord' => true, + 'withhash' => true, + 'count' => 1, + 'sort' => 'asc', + ), + ); + + $expected = array( + 'Sicily', 15, 37, 200, 'km', + 'WITHCOORD', 'WITHDIST', 'WITHHASH', 'COUNT', 1, 'ASC', 'STORE', 'key:store', 'STOREDIST', 'key:storedist' + ); + + $command = $this->getCommand(); + $command->setArguments($arguments); + + $this->assertSame($expected, $command->getArguments()); + } + + /** + * @group disconnected + */ + public function testFilterArgumentsWithSpecificOptionsSetToFalse() + { + $arguments = array( + 'Sicily', 15, 37, 200, 'km', array( + 'store' => 'key:store', + 'storedist' => 'key:storedist', + 'withdist' => false, + 'withcoord' => false, + 'withhash' => false, + 'count' => 1, + 'sort' => 'asc', + ), + ); + + $expected = array('Sicily', 15, 37, 200, 'km', 'COUNT', 1, 'ASC', 'STORE', 'key:store', 'STOREDIST', 'key:storedist'); + + $command = $this->getCommand(); + $command->setArguments($arguments); + + $this->assertSame($expected, $command->getArguments()); + } + + /** + * @group disconnected + */ + public function testParseResponseWithNoOptions() + { + $raw = array( + array('Palermo', '190.4424'), + array('Catania', '56.4413'), + ); + + $expected = array( + array('Palermo', '190.4424'), + array('Catania', '56.4413'), + ); + + $command = $this->getCommand(); + + $this->assertSame($expected, $command->parseResponse($raw)); + } + + /** + * @group connected + * @requiresRedisVersion >= 3.2.0 + */ + public function testCommandReturnsGeoRadiusInfoWithNoOptions() + { + $redis = $this->getClient(); + + $redis->geoadd('Sicily', '13.361389', '38.115556', 'Palermo', '15.087269', '37.502669', 'Catania'); + $this->assertEquals(array('Palermo', 'Catania'), $redis->georadius('Sicily', 15, 37, 200, 'km')); + } + + /** + * @group connected + * @requiresRedisVersion >= 3.2.0 + */ + public function testCommandReturnsGeoRadiusInfoWithOptions() + { + $redis = $this->getClient(); + + $redis->geoadd('Sicily', '13.361389', '38.115556', 'Palermo', '15.087269', '37.502669', 'Catania'); + $this->assertEquals(array( + array('Palermo', '190.4424', array('13.361389338970184', '38.115556395496299')), + array('Catania', '56.4413', array('15.087267458438873', '37.50266842333162')), + ), $redis->georadius('Sicily', 15, 37, 200, 'km', 'WITHDIST', 'WITHCOORD')); + } + + /** + * @group connected + * @requiresRedisVersion >= 3.2.0 + * @expectedException \Predis\Response\ServerException + * @expectedExceptionMessage Operation against a key holding the wrong kind of value + */ + public function testThrowsExceptionOnWrongType() + { + $redis = $this->getClient(); + + $redis->lpush('Sicily', 'Palermo'); + $redis->georadius('Sicily', 15, 37, 200, 'km'); + } +} diff --git a/tests/Predis/Command/Processor/KeyPrefixProcessorTest.php b/tests/Predis/Command/Processor/KeyPrefixProcessorTest.php index 9357c0f0..7de8802b 100644 --- a/tests/Predis/Command/Processor/KeyPrefixProcessorTest.php +++ b/tests/Predis/Command/Processor/KeyPrefixProcessorTest.php @@ -889,6 +889,14 @@ class KeyPrefixProcessorTest extends PredisTestCase array('key', 'member:1', 'member:2', 'km'), array('prefix:key', 'member:1', 'member:2', 'km'), ), + array('GEORADIUS', + array('key', '15', '37', '200', 'km'), + array('prefix:key', '15', '37', '200', 'km'), + ), + array('GEORADIUS', + array('key', '15', '37', '200', 'km', 'WITHDIST', 'STORE', 'key:store', 'STOREDIST', 'key:storedist'), + array('prefix:key', '15', '37', '200', 'km', 'WITHDIST', 'STORE', 'prefix:key:store', 'STOREDIST', 'prefix:key:storedist'), + ), ); } } diff --git a/tests/Predis/Profile/RedisUnstableTest.php b/tests/Predis/Profile/RedisUnstableTest.php index cf412282..13dd93de 100644 --- a/tests/Predis/Profile/RedisUnstableTest.php +++ b/tests/Predis/Profile/RedisUnstableTest.php @@ -195,6 +195,7 @@ class RedisUnstableTest extends PredisProfileTestCase 154 => 'GEOHASH', 155 => 'GEOPOS', 156 => 'GEODIST', + 157 => 'GEORADIUS', ); } } diff --git a/tests/Predis/Profile/RedisVersion320Test.php b/tests/Predis/Profile/RedisVersion320Test.php index 99bff7f2..28780a1e 100644 --- a/tests/Predis/Profile/RedisVersion320Test.php +++ b/tests/Predis/Profile/RedisVersion320Test.php @@ -195,6 +195,7 @@ class RedisVersion320Test extends PredisProfileTestCase 154 => 'GEOHASH', 155 => 'GEOPOS', 156 => 'GEODIST', + 157 => 'GEORADIUS', ); } } diff --git a/tests/Predis/Replication/ReplicationStrategyTest.php b/tests/Predis/Replication/ReplicationStrategyTest.php index ce2dcbbe..8caae3b6 100644 --- a/tests/Predis/Replication/ReplicationStrategyTest.php +++ b/tests/Predis/Replication/ReplicationStrategyTest.php @@ -139,6 +139,33 @@ class ReplicationStrategyTest extends PredisTestCase ); } + /** + * @group disconnected + */ + public function testGeoradiusCommand() + { + $profile = Profile\Factory::getDevelopment(); + $strategy = new ReplicationStrategy(); + + $command = $profile->createCommand('GEORADIUS', array('key:geo', 15, 37, 200, 'km')); + $this->assertTrue( + $strategy->isReadOperation($command), + 'GEORADIUS is expected to be a read operation.' + ); + + $command = $profile->createCommand('GEORADIUS', array('key:geo', 15, 37, 200, 'km', 'store', 'key:store')); + $this->assertFalse( + $strategy->isReadOperation($command), + 'GEORADIUS with STORE is expected to be a write operation.' + ); + + $command = $profile->createCommand('GEORADIUS', array('key:geo', 15, 37, 200, 'km', 'storedist', 'key:storedist')); + $this->assertFalse( + $strategy->isReadOperation($command), + 'GEORADIUS with STOREDIST is expected to be a write operation.' + ); + } + /** * @group disconnected * @expectedException \Predis\NotSupportedException @@ -444,6 +471,7 @@ class ReplicationStrategyTest extends PredisTestCase 'GEOHASH' => 'read', 'GEOPOS' => 'read', 'GEODIST' => 'read', + 'GEORADIUS' => 'variable', ); if (isset($type)) {