From 98222f47b0d42136385a48bcf2b21906c548f320 Mon Sep 17 00:00:00 2001 From: Vladyslav Vildanov <117659936+vladvildanov@users.noreply.github.com> Date: Tue, 31 Jan 2023 18:48:48 +0200 Subject: [PATCH] Added support for container commands (#1049) * Added support for container commands FUNCTION LOAD, FUNCTION DELETE and FCALL * Changed ContainerInterface and AbstractContainer * Re-implement logic of abstract methods --------- Co-authored-by: Vladyslav Vildanov --- src/Client.php | 31 +++ src/ClientContextInterface.php | 5 + src/ClientInterface.php | 5 + .../Redis/Container/AbstractContainer.php | 42 ++++ .../Redis/Container/ContainerFactory.php | 54 ++++++ .../Redis/Container/ContainerInterface.php | 33 ++++ .../Redis/Container/FunctionContainer.php | 27 +++ src/Command/Redis/FCALL.php | 33 ++++ src/Command/Redis/FUNCTIONS.php | 50 +++++ src/Command/RedisFactory.php | 4 + .../Functions/DeleteStrategy.php | 26 +++ .../Functions/LoadStrategy.php | 41 ++++ .../Strategy/StrategyResolverInterface.php | 25 +++ .../Strategy/SubcommandStrategyInterface.php | 24 +++ .../Strategy/SubcommandStrategyResolver.php | 37 ++++ .../Redis/Container/AbstractContainerTest.php | 90 +++++++++ .../Redis/Container/ContainerFactoryTest.php | 64 ++++++ tests/Predis/Command/Redis/FCALL_Test.php | 135 +++++++++++++ tests/Predis/Command/Redis/FUNCTIONS_Test.php | 182 ++++++++++++++++++ .../Functions/DeleteStrategyTest.php | 33 ++++ .../Functions/LoadStrategyTest.php | 59 ++++++ .../SubcommandStrategyResolverTest.php | 51 +++++ 22 files changed, 1051 insertions(+) create mode 100644 src/Command/Redis/Container/AbstractContainer.php create mode 100644 src/Command/Redis/Container/ContainerFactory.php create mode 100644 src/Command/Redis/Container/ContainerInterface.php create mode 100644 src/Command/Redis/Container/FunctionContainer.php create mode 100644 src/Command/Redis/FCALL.php create mode 100644 src/Command/Redis/FUNCTIONS.php create mode 100644 src/Command/Strategy/ContainerCommands/Functions/DeleteStrategy.php create mode 100644 src/Command/Strategy/ContainerCommands/Functions/LoadStrategy.php create mode 100644 src/Command/Strategy/StrategyResolverInterface.php create mode 100644 src/Command/Strategy/SubcommandStrategyInterface.php create mode 100644 src/Command/Strategy/SubcommandStrategyResolver.php create mode 100644 tests/Predis/Command/Redis/Container/AbstractContainerTest.php create mode 100644 tests/Predis/Command/Redis/Container/ContainerFactoryTest.php create mode 100644 tests/Predis/Command/Redis/FCALL_Test.php create mode 100644 tests/Predis/Command/Redis/FUNCTIONS_Test.php create mode 100644 tests/Predis/Command/Strategy/ContainerCommands/Functions/DeleteStrategyTest.php create mode 100644 tests/Predis/Command/Strategy/ContainerCommands/Functions/LoadStrategyTest.php create mode 100644 tests/Predis/Command/Strategy/SubcommandStrategyResolverTest.php diff --git a/src/Client.php b/src/Client.php index a00825f8..35488415 100644 --- a/src/Client.php +++ b/src/Client.php @@ -17,6 +17,8 @@ use InvalidArgumentException; use IteratorAggregate; use Predis\Command\CommandInterface; use Predis\Command\RawCommand; +use Predis\Command\Redis\Container\ContainerFactory; +use Predis\Command\Redis\Container\ContainerInterface; use Predis\Command\ScriptCommand; use Predis\Configuration\Options; use Predis\Configuration\OptionsInterface; @@ -31,6 +33,7 @@ use Predis\Response\ResponseInterface; use Predis\Response\ServerException; use Predis\Transaction\MultiExec as MultiExecTransaction; use ReturnTypeWillChange; +use RuntimeException; use Traversable; /** @@ -310,6 +313,34 @@ class Client implements ClientInterface, IteratorAggregate return $this->commands->create($commandID, $arguments); } + /** + * @param $name + * @return ContainerInterface + */ + public function __get($name) + { + return ContainerFactory::create($this, $name); + } + + /** + * @param $name + * @param $value + * @return mixed + */ + public function __set($name, $value) + { + throw new RuntimeException('Not allowed'); + } + + /** + * @param $name + * @return mixed + */ + public function __isset($name) + { + throw new RuntimeException('Not allowed'); + } + /** * {@inheritdoc} */ diff --git a/src/ClientContextInterface.php b/src/ClientContextInterface.php index e556ab53..c241e83e 100644 --- a/src/ClientContextInterface.php +++ b/src/ClientContextInterface.php @@ -17,6 +17,7 @@ use Predis\Command\Argument\Geospatial\FromInterface; use Predis\Command\Argument\Server\LimitOffsetCount; use Predis\Command\Argument\Server\To; use Predis\Command\CommandInterface; +use Predis\Command\Redis\Container\FunctionContainer; /** * Interface defining a client-side context such as a pipeline or transaction. @@ -55,6 +56,7 @@ use Predis\Command\CommandInterface; * @method $this decr($key) * @method $this decrby($key, $decrement) * @method $this failover(?To $to = null, bool $abort = false, int $timeout = -1) + * @method $this fcall(string $function, array $keys, ...$args) * @method $this get($key) * @method $this getbit($key, $offset) * @method $this getex(string $key, $modifier = '', $value = false) @@ -200,6 +202,9 @@ use Predis\Command\CommandInterface; * @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) + * + * Container commands + * @property FunctionContainer $function */ interface ClientContextInterface { diff --git a/src/ClientInterface.php b/src/ClientInterface.php index b3263ee1..f04f1a9f 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -18,6 +18,7 @@ use Predis\Command\Argument\Server\LimitOffsetCount; use Predis\Command\Argument\Server\To; use Predis\Command\CommandInterface; use Predis\Command\FactoryInterface; +use Predis\Command\Redis\Container\FunctionContainer; use Predis\Configuration\OptionsInterface; use Predis\Connection\ConnectionInterface; use Predis\Response\Status; @@ -64,6 +65,7 @@ use Predis\Response\Status; * @method int decr(string $key) * @method int decrby(string $key, int $decrement) * @method Status failover(?To $to = null, bool $abort = false, int $timeout = -1) + * @method mixed fcall(string $function, array $keys, ...$args) * @method string|null get(string $key) * @method int getbit(string $key, $offset) * @method int|null getex(string $key, $modifier = '', $value = false) @@ -218,6 +220,9 @@ use Predis\Response\Status; * @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) + * + * Container commands + * @property FunctionContainer $function */ interface ClientInterface { diff --git a/src/Command/Redis/Container/AbstractContainer.php b/src/Command/Redis/Container/AbstractContainer.php new file mode 100644 index 00000000..6ba86d0f --- /dev/null +++ b/src/Command/Redis/Container/AbstractContainer.php @@ -0,0 +1,42 @@ +client = $client; + } + + /** + * {@inheritDoc} + */ + public function __call($subcommandID, $arguments) + { + array_unshift($arguments, strtoupper($subcommandID)); + + return $this->client->executeCommand( + $this->client->createCommand($this->getContainerCommandId(), $arguments) + ); + } + + abstract public function getContainerCommandId(): string; +} diff --git a/src/Command/Redis/Container/ContainerFactory.php b/src/Command/Redis/Container/ContainerFactory.php new file mode 100644 index 00000000..c9a0665f --- /dev/null +++ b/src/Command/Redis/Container/ContainerFactory.php @@ -0,0 +1,54 @@ + FunctionContainer::class, + ]; + + /** + * Creates container command. + * + * @param ClientInterface $client + * @param string $containerCommandID + * @return ContainerInterface + */ + public static function create(ClientInterface $client, string $containerCommandID): ContainerInterface + { + $containerCommandID = strtoupper($containerCommandID); + + if (class_exists($containerClass = self::CONTAINER_NAMESPACE . '\\' . $containerCommandID)) { + return new $containerClass($client); + } + + if (array_key_exists($containerCommandID, self::$specialMappings)) { + $containerClass = self::$specialMappings[$containerCommandID]; + + return new $containerClass($client); + } + + throw new UnexpectedValueException('Given command is not supported.'); + } +} diff --git a/src/Command/Redis/Container/ContainerInterface.php b/src/Command/Redis/Container/ContainerInterface.php new file mode 100644 index 00000000..ce3989b5 --- /dev/null +++ b/src/Command/Redis/Container/ContainerInterface.php @@ -0,0 +1,33 @@ +strategyResolver = new SubcommandStrategyResolver(); + } + + public function getId() + { + return 'FUNCTION'; + } + + public function setArguments(array $arguments) + { + $strategy = $this->strategyResolver->resolve('functions', $arguments[0]); + $arguments = $strategy->processArguments($arguments); + + parent::setArguments($arguments); + $this->filterArguments(); + } +} diff --git a/src/Command/RedisFactory.php b/src/Command/RedisFactory.php index 4e945573..ed845f31 100644 --- a/src/Command/RedisFactory.php +++ b/src/Command/RedisFactory.php @@ -12,6 +12,8 @@ namespace Predis\Command; +use Predis\Command\Redis\FUNCTIONS; + /** * Command factory for mainline Redis servers. * @@ -29,6 +31,8 @@ class RedisFactory extends Factory 'ECHO' => 'Predis\Command\Redis\ECHO_', 'EVAL' => 'Predis\Command\Redis\EVAL_', 'OBJECT' => 'Predis\Command\Redis\OBJECT_', + // Class name corresponds to PHP reserved word "function", added mapping to bypass restrictions + 'FUNCTION' => FUNCTIONS::class, ]; } diff --git a/src/Command/Strategy/ContainerCommands/Functions/DeleteStrategy.php b/src/Command/Strategy/ContainerCommands/Functions/DeleteStrategy.php new file mode 100644 index 00000000..250ae744 --- /dev/null +++ b/src/Command/Strategy/ContainerCommands/Functions/DeleteStrategy.php @@ -0,0 +1,26 @@ +arguments = ['arg1', 'arg2']; + $this->expectedValue = ['value']; + $this->mockCommand = $this->getMockBuilder(CommandInterface::class)->getMock(); + $this->mockClient = $this->getMockBuilder(ClientInterface::class)->getMock(); + + $this->testClass = new class($this->mockClient) extends AbstractContainer { + public function getContainerCommandId(): string + { + return 'test'; + } + }; + } + + /** + * @return void + */ + public function testGetContainerId(): void + { + $this->assertSame('test', $this->testClass->getContainerCommandId()); + } + + /** + * @return void + */ + public function testCallReturnsValidCommandResponse(): void + { + $modifiedArguments = ['TEST', ['arg1', 'arg2']]; + + $this->mockClient + ->expects($this->once()) + ->method('createCommand') + ->with($this->equalTo('test'), $modifiedArguments) + ->willReturn($this->mockCommand); + + $this->mockClient + ->expects($this->once()) + ->method('executeCommand') + ->with($this->mockCommand) + ->willReturn($this->expectedValue); + + $this->assertSame($this->expectedValue, $this->testClass->test($this->arguments)); + } +} diff --git a/tests/Predis/Command/Redis/Container/ContainerFactoryTest.php b/tests/Predis/Command/Redis/Container/ContainerFactoryTest.php new file mode 100644 index 00000000..cf6d28c4 --- /dev/null +++ b/tests/Predis/Command/Redis/Container/ContainerFactoryTest.php @@ -0,0 +1,64 @@ +mockClient = $this->getMockBuilder(ClientInterface::class)->getMock(); + $this->expectedContainer = new FunctionContainer($this->mockClient); + $this->factory = new ContainerFactory(); + } + + /** + * @return void + */ + public function testCreatesReturnsExistingCommandContainerClass(): void + { + $this->assertEquals( + $this->expectedContainer, + $this->factory::create($this->mockClient, 'function') + ); + } + + /** + * @return void + */ + public function testThrowsExceptionOnNonExistingCommand(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Given command is not supported.'); + + $this->factory::create($this->mockClient, 'foobar'); + } +} diff --git a/tests/Predis/Command/Redis/FCALL_Test.php b/tests/Predis/Command/Redis/FCALL_Test.php new file mode 100644 index 00000000..516032af --- /dev/null +++ b/tests/Predis/Command/Redis/FCALL_Test.php @@ -0,0 +1,135 @@ +getCommand(); + $command->setArguments($actualArguments); + + $this->assertSame($expectedArguments, $command->getArguments()); + } + + /** + * @group disconnected + */ + public function testParseResponse(): void + { + $this->assertSame(1, $this->getCommand()->parseResponse(1)); + } + + /** + * @group connected + * @dataProvider functionsProvider + * @param string $function + * @param array $functionArguments + * @param $expectedResponse + * @return void + * @requiresRedisVersion >= 7.0.0 + */ + public function testInvokeGivenFunction( + string $function, + array $functionArguments, + $expectedResponse + ): void { + $redis = $this->getClient(); + + $this->assertSame('mylib', $redis->function->load($function)); + + $actualResponse = $redis->fcall(...$functionArguments); + $this->assertSame($expectedResponse, $actualResponse); + $this->assertEquals('OK', $redis->function->delete('mylib')); + } + + /** + * @group connected + * @return void + * @requiresRedisVersion >= 7.0.0 + */ + public function testThrowsExceptionOnNonExistingFunctionGiven(): void + { + $redis = $this->getClient(); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage('ERR Function not found'); + + $redis->fcall('function', []); + } + + public function argumentsProvider(): array + { + return [ + 'with default arguments' => [ + ['function', []], + ['function', 0], + ], + 'with provided keys' => [ + ['function', ['key1', 'key2']], + ['function', 2, 'key1', 'key2'], + ], + 'with provided keys and arguments' => [ + ['function', ['key1', 'key2'], 'arg1', 'arg2'], + ['function', 2, 'key1', 'key2', 'arg1', 'arg2'], + ], + ]; + } + + public function functionsProvider(): array + { + return [ + 'with default arguments' => [ + "#!lua name=mylib \n redis.register_function('myfunc', function(keys, args) return 'hello' end)", + ['myfunc', []], + 'hello', + ], + 'with provided keys' => [ + "#!lua name=mylib \n redis.register_function('myfunc', function(keys, args) return keys[1] end)", + ['myfunc', ['key1']], + 'key1', + ], + 'with provided keys and arguments' => [ + "#!lua name=mylib \n redis.register_function('myfunc', function(keys, args) return keys[1] .. ' ' .. args[1] end)", + ['myfunc', ['key1'], 'arg1'], + 'key1 arg1', + ], + ]; + } +} diff --git a/tests/Predis/Command/Redis/FUNCTIONS_Test.php b/tests/Predis/Command/Redis/FUNCTIONS_Test.php new file mode 100644 index 00000000..3bf838cd --- /dev/null +++ b/tests/Predis/Command/Redis/FUNCTIONS_Test.php @@ -0,0 +1,182 @@ +getCommand(); + $command->setArguments($arguments); + + $this->assertSameValues($expected, $command->getArguments()); + } + + /** + * @group disconnected + */ + public function testDeleteFilterArguments(): void + { + $arguments = ['DELETE', 'libraryName']; + $expected = ['DELETE', 'libraryName']; + + $command = $this->getCommand(); + $command->setArguments($arguments); + + $this->assertSameValues($expected, $command->getArguments()); + } + + /** + * @group disconnected + */ + public function testParseResponse(): void + { + $this->assertSame(1, $this->getCommand()->parseResponse(1)); + } + + /** + * @group connected + * @return void + * @requiresRedisVersion >= 7.0.0 + */ + public function testLoadFunctionAddFunctionIntoGivenLibrary(): void + { + $redis = $this->getClient(); + + $actualResponse = $redis->function->load( + "#!lua name={$this->libName} \n redis.register_function('myfunc', function(keys, args) return args[1] end)" + ); + + $this->assertSame('mylib', $actualResponse); + $this->assertSame('arg1', $redis->fcall('myfunc', [], 'arg1')); + $this->assertEquals('OK', $redis->function->delete($this->libName)); + } + + /** + * @group connected + * @return void + * @requiresRedisVersion >= 7.0.0 + */ + public function testLoadFunctionOverridesExistingFunctionWithReplaceArgumentGiven(): void + { + $redis = $this->getClient(); + + $actualResponse = $redis->function->load( + "#!lua name={$this->libName} \n redis.register_function('myfunc', function(keys, args) return args[1] end)" + ); + + $this->assertSame($this->libName, $actualResponse); + $this->assertSame('arg1', $redis->fcall('myfunc', [], 'arg1')); + + $overriddenResponse = $redis->function->load( + "#!lua name={$this->libName} \n redis.register_function('myfunc', function(keys, args) return args[2] end)", + true + ); + + $this->assertSame($this->libName, $overriddenResponse); + $this->assertSame('arg2', $redis->fcall('myfunc', [], 'arg1', 'arg2')); + $this->assertEquals('OK', $redis->function->delete($this->libName)); + } + + /** + * @group connected + * @return void + * @requiresRedisVersion >= 7.0.0 + */ + public function testLoadFunctionThrowsErrorOnAlreadyExistingLibraryGiven(): void + { + $redis = $this->getClient(); + + $actualResponse = $redis->function->load( + "#!lua name={$this->libName} \n redis.register_function('myfunc', function(keys, args) return args[1] end)" + ); + + $this->assertSame($this->libName, $actualResponse); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage("ERR Library '{$this->libName}' already exists"); + + try { + $redis->function->load( + "#!lua name={$this->libName} \n redis.register_function('myfunc', function(keys, args) return args[1] end)" + ); + } finally { + $this->assertEquals('OK', $redis->function->delete($this->libName)); + } + } + + /** + * @group connected + * @return void + * @requiresRedisVersion >= 7.0.0 + */ + public function testDeleteFunctionRemovesAlreadyExistingLibrary(): void + { + $redis = $this->getClient(); + + $actualResponse = $redis->function->load( + "#!lua name={$this->libName} \n redis.register_function('myfunc', function(keys, args) return args[1] end)" + ); + + $this->assertSame($this->libName, $actualResponse); + $this->assertEquals('OK', $redis->function->delete($this->libName)); + } + + /** + * @group connected + * @return void + * @requiresRedisVersion >= 7.0.0 + */ + public function testDeleteFunctionThrowsErrorOnNonExistingLibrary(): void + { + $redis = $this->getClient(); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage('ERR Library not found'); + + $redis->function->delete($this->libName); + } +} diff --git a/tests/Predis/Command/Strategy/ContainerCommands/Functions/DeleteStrategyTest.php b/tests/Predis/Command/Strategy/ContainerCommands/Functions/DeleteStrategyTest.php new file mode 100644 index 00000000..6169f8b0 --- /dev/null +++ b/tests/Predis/Command/Strategy/ContainerCommands/Functions/DeleteStrategyTest.php @@ -0,0 +1,33 @@ +strategy = new DeleteStrategy(); + } + + public function testProcessArguments(): void + { + $this->assertSame(['arg1', 'arg2'], $this->strategy->processArguments(['arg1', 'arg2'])); + } +} diff --git a/tests/Predis/Command/Strategy/ContainerCommands/Functions/LoadStrategyTest.php b/tests/Predis/Command/Strategy/ContainerCommands/Functions/LoadStrategyTest.php new file mode 100644 index 00000000..7d08daee --- /dev/null +++ b/tests/Predis/Command/Strategy/ContainerCommands/Functions/LoadStrategyTest.php @@ -0,0 +1,59 @@ +strategy = new LoadStrategy(); + } + + /** + * @dataProvider argumentsProvider + * @param array $actualArguments + * @param array $expectedArguments + * @return void + */ + public function testProcessArgumentsReturnsCorrectArguments( + array $actualArguments, + array $expectedArguments + ): void { + $this->assertSame($expectedArguments, $this->strategy->processArguments($actualArguments)); + } + + public function argumentsProvider(): array + { + return [ + 'with less then or equal 2 arguments' => [ + ['arg1', 'arg2'], + ['arg1', 'arg2'], + ], + 'with last argument equals true' => [ + ['arg1', 'arg2', true], + ['arg1', 'REPLACE', 'arg2'], + ], + 'with last argument equals false' => [ + ['arg1', 'arg2', false], + ['arg1', 'arg2'], + ], + ]; + } +} diff --git a/tests/Predis/Command/Strategy/SubcommandStrategyResolverTest.php b/tests/Predis/Command/Strategy/SubcommandStrategyResolverTest.php new file mode 100644 index 00000000..3532809c --- /dev/null +++ b/tests/Predis/Command/Strategy/SubcommandStrategyResolverTest.php @@ -0,0 +1,51 @@ +resolver = new SubcommandStrategyResolver(); + } + + /** + * @return void + */ + public function testResolveCorrectStrategy(): void + { + $expectedStrategy = new LoadStrategy(); + + $this->assertEquals($expectedStrategy, $this->resolver->resolve('functions', 'load')); + } + + /** + * @return void + */ + public function testResolveThrowsExceptionOnNonExistingStrategy(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Non-existing container command given'); + + $this->resolver->resolve('foo', 'bar'); + } +}