From b47aa7ad3a7bf3c85f74d2ac50de885f65052c97 Mon Sep 17 00:00:00 2001 From: Vladyslav Vildanov <117659936+vladvildanov@users.noreply.github.com> Date: Thu, 29 Dec 2022 22:16:51 +0200 Subject: [PATCH] Extended Geospatial support by implementing GEOSEARCHSTORE command (#873) * Added arrayable arguments classes * Updated count trait to accept additional modifier argument * Added new With traits, moved into separate directory * Added traits for geo command arguments resolving * Remove BaseWith trait, not working with traits nesting * Added AscDesc trait * Rename AscDesc traint into Sorting * Fixed trait keyword * Removed unnecessary traits * Updated count trait to handle default argument value * Fixed With traits offset check condition * Added GeoFrom, GeoBy traits * Fixes for Count and Sorting traits * Fixed variable names in with traits * [WIP] Added GEOSEARCH command support, without test coverage * Changed functionality to support only lower case units (Redis 6.0 support) * Changed namespace for WITHSCORES trait * Added more test coverage, added response parsing * Added support for GEOSEARCHSTORE command * Added test coverage for tratis Co-authored-by: Vladyslav Vildanov Co-authored-by: Chayim --- src/ClientContextInterface.php | 1 + src/ClientInterface.php | 1 + src/Command/Redis/GEORADIUS.php | 5 + src/Command/Redis/GEORADIUSBYMEMBER.php | 5 + src/Command/Redis/GEOSEARCHSTORE.php | 61 +++ src/Command/Traits/Storedist.php | 38 ++ .../Command/Redis/GEOSEARCHSTORE_Test.php | 354 ++++++++++++++++++ tests/Predis/Command/Traits/StoredistTest.php | 83 ++++ 8 files changed, 548 insertions(+) create mode 100644 src/Command/Redis/GEOSEARCHSTORE.php create mode 100644 src/Command/Traits/Storedist.php create mode 100644 tests/Predis/Command/Redis/GEOSEARCHSTORE_Test.php create mode 100644 tests/Predis/Command/Traits/StoredistTest.php diff --git a/src/ClientContextInterface.php b/src/ClientContextInterface.php index 7041dda3..23fc0d3f 100644 --- a/src/ClientContextInterface.php +++ b/src/ClientContextInterface.php @@ -185,6 +185,7 @@ use Predis\Command\CommandInterface; * @method $this georadius($key, $longitude, $latitude, $radius, $unit, array $options = null) * @method $this georadiusbymember($key, $member, $radius, $unit, array $options = null) * @method $this geosearch(string $key, FromInterface $from, ByInterface $by, ?string $sorting = null, int $count = -1, bool $any = false, bool $withCoord = false, bool $withDist = false, bool $withHash = false) + * @method $this geosearchstore(string $destination, string $source, FromInterface $from, ByInterface $by, ?string $sorting = null, int $count = -1, bool $any = false, bool $storeDist = false) * * @author Daniele Alessandri */ diff --git a/src/ClientInterface.php b/src/ClientInterface.php index 2134528f..76baf94b 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -203,6 +203,7 @@ use Predis\Response\Status; * @method array georadius(string $key, $longitude, $latitude, $radius, $unit, array $options = null) * @method array georadiusbymember(string $key, $member, $radius, $unit, array $options = null) * @method array geosearch(string $key, FromInterface $from, ByInterface $by, ?string $sorting = null, int $count = -1, bool $any = false, bool $withCoord = false, bool $withDist = false, bool $withHash = false) + * @method int geosearchstore(string $destination, string $source, FromInterface $from, ByInterface $by, ?string $sorting = null, int $count = -1, bool $any = false, bool $storeDist = false) * * @author Daniele Alessandri */ diff --git a/src/Command/Redis/GEORADIUS.php b/src/Command/Redis/GEORADIUS.php index 748c0e2d..c71a780a 100644 --- a/src/Command/Redis/GEORADIUS.php +++ b/src/Command/Redis/GEORADIUS.php @@ -14,6 +14,11 @@ namespace Predis\Command\Redis; use Predis\Command\Command as RedisCommand; /** + * @deprecated As of Redis version 6.2.0, this command is regarded as deprecated. + * + * It can be replaced by GEOSEARCH and GEOSEARCHSTORE with the BYRADIUS argument + * when migrating or writing new code. + * * @link http://redis.io/commands/georadius * * @author Daniele Alessandri diff --git a/src/Command/Redis/GEORADIUSBYMEMBER.php b/src/Command/Redis/GEORADIUSBYMEMBER.php index 2fe8a538..a6df2a6e 100644 --- a/src/Command/Redis/GEORADIUSBYMEMBER.php +++ b/src/Command/Redis/GEORADIUSBYMEMBER.php @@ -12,6 +12,11 @@ namespace Predis\Command\Redis; /** + * @deprecated As of Redis version 6.2.0, this command is regarded as deprecated. + * + * It can be replaced by GEOSEARCH and GEOSEARCHSTORE with the FROMMEMBER arguments + * when migrating or writing new code. + * * @link http://redis.io/commands/georadiusbymember * * @author Daniele Alessandri diff --git a/src/Command/Redis/GEOSEARCHSTORE.php b/src/Command/Redis/GEOSEARCHSTORE.php new file mode 100644 index 00000000..f5f01b70 --- /dev/null +++ b/src/Command/Redis/GEOSEARCHSTORE.php @@ -0,0 +1,61 @@ +setStoreDist($arguments); + $arguments = $this->getArguments(); + + $this->setCount($arguments, $arguments[6] ?? false); + $arguments = $this->getArguments(); + + $this->setSorting($arguments); + $arguments = $this->getArguments(); + + $this->setFrom($arguments); + $arguments = $this->getArguments(); + + $this->setBy($arguments); + $this->filterArguments(); + } +} diff --git a/src/Command/Traits/Storedist.php b/src/Command/Traits/Storedist.php new file mode 100644 index 00000000..92b3b4f0 --- /dev/null +++ b/src/Command/Traits/Storedist.php @@ -0,0 +1,38 @@ += $argumentsLength + || false === $arguments[static::$storeDistArgumentPositionOffset] + ) { + parent::setArguments($arguments); + return; + } + + $argument = $arguments[static::$storeDistArgumentPositionOffset]; + + if (true === $argument) { + $argument = 'STOREDIST'; + } else { + throw new UnexpectedValueException("Wrong STOREDIST argument type"); + } + + $argumentsBefore = array_slice($arguments, 0, static::$storeDistArgumentPositionOffset); + $argumentsAfter = array_slice($arguments, static::$storeDistArgumentPositionOffset + 1); + + parent::setArguments(array_merge($argumentsBefore, [$argument], $argumentsAfter)); + } +} diff --git a/tests/Predis/Command/Redis/GEOSEARCHSTORE_Test.php b/tests/Predis/Command/Redis/GEOSEARCHSTORE_Test.php new file mode 100644 index 00000000..5f57c839 --- /dev/null +++ b/tests/Predis/Command/Redis/GEOSEARCHSTORE_Test.php @@ -0,0 +1,354 @@ +getCommand(); + $command->setArguments($actualArguments); + + $this->assertSameValues($expectedArguments, $command->getArguments()); + } + + /** + * @group disconnected + */ + public function testParseResponse(): void + { + $this->assertSame(1, $this->getCommand()->parseResponse(1)); + } + + /** + * @group connected + * @dataProvider coordinatesProvider + * @param array $firstCoordinates + * @param array $secondCoordinates + * @param array $thirdCoordinates + * @param string $destination + * @param string $source + * @param FromInterface $from + * @param ByInterface $by + * @param string|null $sorting + * @param int $count + * @param bool $any + * @param int $expectedResultingElements + * @param array $expectedResponse + * @return void + * @requiresRedisVersion >= 6.2.0 + */ + public function testStoresCorrectGivenGeospatialCoordinates( + array $firstCoordinates, + array $secondCoordinates, + array $thirdCoordinates, + string $destination, + string $source, + FromInterface $from, + ByInterface $by, + ?string $sorting, + int $count, + bool $any, + int $expectedResultingElements, + array $expectedResponse + ): void { + $redis = $this->getClient(); + + $redis->geoadd(...$firstCoordinates); + $redis->geoadd(...$secondCoordinates); + $redis->geoadd(...$thirdCoordinates); + + $actualResultingElements = $redis->geosearchstore( + $destination, + $source, + $from, + $by, + $sorting, + $count, + $any + ); + + $this->assertSame($expectedResultingElements, $actualResultingElements); + $this->assertSame($expectedResponse, $redis->geosearch($destination, $from, $by, $sorting, $count, $any)); + } + + /** + * @group connected + * @return void + * @requiresRedisVersion >= 6.2.0 + */ + public function testStoresInSortedSetWithStoreDistArgumentProvided(): void + { + $redis = $this->getClient(); + + $redis->geoadd('key', 1.1, 2, 'member1'); + $redis->geoadd('key', 2.1, 3, 'member2'); + $redis->geoadd('key', 3.1, 4, 'member3'); + + $actualResultingElements = $redis->geosearchstore( + 'destination', + 'key', + new FromLonLat(1, 4), + new ByRadius(9999, 'km'), + null, + 2, + false, + true + ); + + $this->assertSame(2, $actualResultingElements); + $this->assertSame(['member2', 'member1'], $redis->zrange('destination', 0, -1)); + } + + /** + * @group connected + * @dataProvider unexpectedValuesProvider + * @param array $arguments + * @param string $expectedException + * @param string $expectedExceptionMessage + * @return void + * @requiresRedisVersion >= 6.2.0 + */ + public function testThrowsExceptionOnUnexpectedValueProvided( + array $arguments, + string $expectedException, + string $expectedExceptionMessage + ): void { + $redis = $this->getClient(); + + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); + + $redis->geosearchstore(...$arguments); + } + + public function argumentsProvider(): array + { + return [ + 'with default arguments - FROMLONLAT, BYRADIUS' => [ + ['destination', 'source', new FromLonLat(1.1, 2.2), new ByRadius(1, 'km')], + ['destination', 'source', 'FROMLONLAT', 1.1, 2.2, 'BYRADIUS', 1, 'km'] + ], + 'with default arguments - FROMMEMBER, BYBOX' => [ + ['destination', 'source', new FromMember('member'), new ByBox(1,1, 'km')], + ['destination', 'source', 'FROMMEMBER', 'member', 'BYBOX', 1, 1, 'km'] + ], + 'with ASC sorting' => [ + ['destination', 'source', new FromLonLat(1.1, 2.2), new ByRadius(1, 'km'), 'asc'], + ['destination', 'source', 'FROMLONLAT', 1.1, 2.2, 'BYRADIUS', 1, 'km', 'ASC'] + ], + 'with DESC sorting' => [ + ['destination', 'source', new FromLonLat(1.1, 2.2), new ByRadius(1, 'km'), 'desc'], + ['destination', 'source', 'FROMLONLAT', 1.1, 2.2, 'BYRADIUS', 1, 'km', 'DESC'] + ], + 'with COUNT argument - without ANY option' => [ + ['destination', 'source', new FromLonLat(1.1, 2.2), new ByRadius(1, 'km'), null, 20], + ['destination', 'source', 'FROMLONLAT', 1.1, 2.2, 'BYRADIUS', 1, 'km', 'COUNT', 20] + ], + 'with COUNT argument - with ANY option' => [ + ['destination', 'source', new FromLonLat(1.1, 2.2), new ByRadius(1, 'km'), null, 20, true], + ['destination', 'source', 'FROMLONLAT', 1.1, 2.2, 'BYRADIUS', 1, 'km', 'COUNT', 20, 'ANY'] + ], + 'with STOREDIST argument' => [ + ['destination', 'source', new FromLonLat(1.1, 2.2), new ByRadius(1, 'km'), null, -1, false, true], + ['destination', 'source', 'FROMLONLAT', 1.1, 2.2, 'BYRADIUS', 1, 'km', 'STOREDIST'] + ], + 'with all arguments' => [ + ['destination', 'source', new FromLonLat(1.1, 2.2), new ByRadius(1, 'km'), 'asc', 20, true, true], + ['destination', 'source', 'FROMLONLAT', 1.1, 2.2, 'BYRADIUS', 1, 'km', 'ASC', 'COUNT', 20, 'ANY', 'STOREDIST'] + ] + ]; + } + + public function coordinatesProvider(): array + { + return [ + 'with default arguments - FROMLONLAT, BYRADIUS - all members' => [ + ['key', 1.1, 2, 'member1'], + ['key', 2.1, 3, 'member2'], + ['key', 3.1, 4, 'member3'], + 'destination', + 'key', + new FromLonLat(1, 4), + new ByRadius(9999, 'km'), + null, + -1, + false, + 3, + ['member1', 'member2', 'member3'] + ], + 'with default arguments - FROMLONLAT, BYRADIUS - closest members' => [ + ['key', 1.1, 2, 'member1'], + ['key', 2.1, 3, 'member2'], + ['key', 3.1, 4, 'member3'], + 'destination', + 'key', + new FromLonLat(1, 3), + new ByRadius(200, 'km'), + null, + -1, + false, + 2, + ['member2', 'member1'] + ], + 'with default arguments - FROMMEMBER, BYBOX - all members' => [ + ['key', 1.1, 2, 'member1'], + ['key', 2.1, 3, 'member2'], + ['key', 3.1, 4, 'member3'], + 'destination', + 'key', + new FromMember('member2'), + new ByBox(999, 999, 'km'), + null, + -1, + false, + 3, + ['member1', 'member2', 'member3'] + ], + 'with default arguments - FROMMEMBER, BYBOX - closest members' => [ + ['key', 1.1, 2, 'member1'], + ['key', 2.1, 3, 'member2'], + ['key', 3.1, 4, 'member3'], + 'destination', + 'key', + new FromMember('member1'), + new ByBox(300, 300, 'km'), + null, + -1, + false, + 2, + ['member1', 'member2'] + ], + 'with ASC modifier' => [ + ['key', 1.1, 2, 'member1'], + ['key', 2.1, 3, 'member2'], + ['key', 3.1, 4, 'member3'], + 'destination', + 'key', + new FromLonLat(1, 4), + new ByRadius(9999, 'km'), + 'asc', + -1, + false, + 3, + ['member2', 'member1', 'member3'] + ], + 'with DESC modifier' => [ + ['key', 1.1, 2, 'member1'], + ['key', 2.1, 3, 'member2'], + ['key', 3.1, 4, 'member3'], + 'destination', + 'key', + new FromLonLat(1, 4), + new ByRadius(9999, 'km'), + 'desc', + -1, + false, + 3, + ['member3', 'member1', 'member2'] + ], + 'with COUNT modifier - without ANY option' => [ + ['key', 1.1, 2, 'member1'], + ['key', 2.1, 3, 'member2'], + ['key', 3.1, 4, 'member3'], + 'destination', + 'key', + new FromLonLat(1, 4), + new ByRadius(9999, 'km'), + null, + 1, + false, + 1, + ['member2'] + ], + 'with COUNT modifier - with ANY option' => [ + ['key', 1.1, 2, 'member1'], + ['key', 2.1, 3, 'member2'], + ['key', 3.1, 4, 'member3'], + 'destination', + 'key', + new FromLonLat(1, 4), + new ByRadius(9999, 'km'), + null, + 2, + true, + 2, + ['member1', 'member2'] + ], + 'with all arguments' => [ + ['key', 1.1, 2, 'member1'], + ['key', 2.1, 3, 'member2'], + ['key', 3.1, 4, 'member3'], + 'destination', + 'key', + new FromLonLat(1, 4), + new ByRadius(9999, 'km'), + 'asc', + 2, + true, + 2, + ['member2', 'member1'] + ], + ]; + } + + public function unexpectedValuesProvider(): array + { + return [ + 'with wrong FROM argument' => [ + ['destination', 'source', false, new ByRadius(9999, 'km'), null, -1, false, false], + InvalidArgumentException::class, + 'Invalid FROM argument value given' + ], + 'with wrong BY argument' => [ + ['destination', 'source', new FromLonLat(1, 4), false, null, -1, false, false], + InvalidArgumentException::class, + 'Invalid BY argument value given' + ], + 'with wrong sorting argument' => [ + ['destination', 'source', new FromLonLat(1, 4), new ByRadius(9999, 'km'), 'wrong', -1, false, false], + UnexpectedValueException::class, + 'Sorting argument accepts only: asc, desc values' + ], + 'with wrong COUNT argument' => [ + ['destination', 'source', new FromLonLat(1, 4), new ByRadius(9999, 'km'), null, 0, false, false], + UnexpectedValueException::class, + 'Wrong count argument value or position offset' + ], + 'with wrong STOREDIST argument' => [ + ['destination', 'source', new FromLonLat(1, 4), new ByRadius(9999, 'km'), null, 0, false, 'wrong'], + UnexpectedValueException::class, + 'Wrong STOREDIST argument type' + ], + ]; + } +} diff --git a/tests/Predis/Command/Traits/StoredistTest.php b/tests/Predis/Command/Traits/StoredistTest.php new file mode 100644 index 00000000..f7167f81 --- /dev/null +++ b/tests/Predis/Command/Traits/StoredistTest.php @@ -0,0 +1,83 @@ +testClass = new class extends RedisCommand { + use Storedist; + + public static $storeDistArgumentPositionOffset = 0; + + public function getId() + { + return 'test'; + } + }; + } + + /** + * @dataProvider argumentsProvider + * @param int $offset + * @param array $actualArguments + * @param array $expectedArguments + * @return void + */ + public function testReturnsCorrectArguments(int $offset, array $actualArguments, array $expectedArguments): void + { + $this->testClass::$storeDistArgumentPositionOffset = $offset; + + $this->testClass->setArguments($actualArguments); + + $this->assertSame($expectedArguments, $this->testClass->getArguments()); + } + + /** + * @return void + */ + public function testThrowsExceptionOnUnexpectedValue(): void + { + $this->testClass::$storeDistArgumentPositionOffset = 0; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage("Wrong STOREDIST argument type"); + + $this->testClass->setArguments(['test']); + } + + public function argumentsProvider(): array + { + return [ + 'STOREDIST false argument' => [ + 0, + [false, 'second argument', 'third argument'], + [false, 'second argument', 'third argument'] + ], + 'STOREDIST argument first and there is arguments after' => [ + 0, + [true, 'second argument', 'third argument'], + ['STOREDIST', 'second argument', 'third argument'] + ], + 'STOREDIST argument last and there is arguments before' => [ + 2, + ['first argument', 'second argument', true], + ['first argument', 'second argument', 'STOREDIST'] + ], + 'STOREDIST argument not the first and not the last' => [ + 1, + ['first argument', true, 'third argument'], + ['first argument', 'STOREDIST', 'third argument'] + ], + ]; + } +}