From 4ffcfafc4b179c4f7b8590949e0f1d41c01aae6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 2 Feb 2025 21:46:07 +0100 Subject: [PATCH] Automatically convert numeric arguments passed to any Redis commands --- README.md | 21 +++++++------- src/RedisClient.php | 31 +++++++++++++-------- tests/RedisClientTest.php | 58 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b1c24fe..6652011 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ Listing all available commands is out of scope here, please refer to the Any arguments passed to the method call will be forwarded as command arguments. For example, the `$redis->set('name', 'Alice')` call will perform the equivalent of a -`SET name Alice` command. It's safe to pass integer arguments where applicable (for +`SET name Alice` command. It's safe to pass numeric arguments where applicable (for example `$redis->expire($key, 60)`), but internally Redis requires all arguments to always be coerced to string values. @@ -417,7 +417,7 @@ $redis = new Clue\React\Redis\RedisClient('localhost', $connector); #### __call() -The `__call(string $name, string[] $args): PromiseInterface` method can be used to +The `__call(string $name, list $args): PromiseInterface` method can be used to invoke the given command. This is a magic method that will be invoked when calling any Redis command on this instance. @@ -441,7 +441,7 @@ Listing all available commands is out of scope here, please refer to the Any arguments passed to the method call will be forwarded as command arguments. For example, the `$redis->set('name', 'Alice')` call will perform the equivalent of a -`SET name Alice` command. It's safe to pass integer arguments where applicable (for +`SET name Alice` command. It's safe to pass numeric arguments where applicable (for example `$redis->expire($key, 60)`), but internally Redis requires all arguments to always be coerced to string values. @@ -451,9 +451,12 @@ that eventually *fulfills* with its *results* on success or *rejects* with an #### callAsync() -The `callAsync(string $command, string ...$args): PromiseInterface` method can be used to +The `callAsync(string $command, string|int|float ...$args): PromiseInterface` method can be used to invoke a Redis command. +For example, the [`GET` command](https://redis.io/commands/get) can be invoked +like this: + ```php $redis->callAsync('GET', 'name')->then(function (?string $name): void { echo 'Name: ' . ($name ?? 'Unknown') . PHP_EOL; @@ -470,12 +473,10 @@ may understand this magic method. Listing all available commands is out of scope here, please refer to the [Redis command reference](https://redis.io/commands). -The optional `string ...$args` parameter can be used to pass any -additional arguments to the Redis command. Some commands may require or -support additional arguments that this method will simply forward as is. -Internally, Redis requires all arguments to be coerced to `string` values, -but you may also rely on PHP's type-juggling semantics and pass `int` or -`float` values: +The optional `string|int|float ...$args` parameter can be used to pass +any additional arguments that some Redis commands may require or support. +Values get passed directly to Redis, with any numeric values converted +automatically since Redis only works with `string` arguments internally: ```php $redis->callAsync('SET', 'name', 'Alice', 'EX', 600); diff --git a/src/RedisClient.php b/src/RedisClient.php index 688e584..32ced43 100644 --- a/src/RedisClient.php +++ b/src/RedisClient.php @@ -165,8 +165,8 @@ private function client(): PromiseInterface * This is a magic method that will be invoked when calling any redis * command on this instance. See also `RedisClient::callAsync()`. * - * @param string $name - * @param string[] $args + * @param string $name + * @param list $args * @return PromiseInterface * @see self::callAsync() */ @@ -197,12 +197,10 @@ public function __call(string $name, array $args): PromiseInterface * of scope here, please refer to the * [Redis command reference](https://redis.io/commands). * - * The optional `string ...$args` parameter can be used to pass any - * additional arguments to the Redis command. Some commands may require or - * support additional arguments that this method will simply forward as is. - * Internally, Redis requires all arguments to be coerced to `string` values, - * but you may also rely on PHP's type-juggling semantics and pass `int` or - * `float` values: + * The optional `string|int|float ...$args` parameter can be used to pass + * any additional arguments that some Redis commands may require or support. + * Values get passed directly to Redis, with any numeric values converted + * automatically since Redis only works with `string` arguments internally: * * ```php * $redis->callAsync('SET', 'name', 'Alice', 'EX', 600); @@ -214,12 +212,23 @@ public function __call(string $name, array $args): PromiseInterface * details. * * @param string $command - * @param string ...$args + * @param string|int|float ...$args * @return PromiseInterface - * @throws void + * @throws \TypeError if given $args are invalid */ - public function callAsync(string $command, string ...$args): PromiseInterface + public function callAsync(string $command, ...$args): PromiseInterface { + $args = \array_map(function ($value): string { + /** @var mixed $value */ + if (\is_string($value)) { + return $value; + } elseif (\is_int($value) || \is_float($value)) { + return \var_export($value, true); + } else { + throw new \TypeError('Argument must be of type string|int|float, ' . (\is_object($value) ? \get_class($value) : \gettype($value)) . ' given'); + } + }, $args); + if ($this->closed) { return reject(new \RuntimeException( 'Connection closed (ENOTCONN)', diff --git a/tests/RedisClientTest.php b/tests/RedisClientTest.php index 99d6b63..785bfbc 100644 --- a/tests/RedisClientTest.php +++ b/tests/RedisClientTest.php @@ -407,6 +407,64 @@ public function testPingFollowedByIdleTimerWillCloseUnderlyingConnectionWithoutC $timeout(); } + public function testBlpopWillForwardArgumentsAsStringToUnderlyingClient(): void + { + $client = $this->createMock(StreamingClient::class); + $client->expects($this->once())->method('callAsync')->with('BLPOP', 'foo', 'bar', '10.0')->willReturn(new Promise(function () { })); + + $this->factory->expects($this->once())->method('createClient')->willReturn(\React\Promise\resolve($client)); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + assert($loop instanceof LoopInterface); + Loop::set($loop); + + $this->redis->callAsync('BLPOP', 'foo', 'bar', 10.0); + } + + public function testCallAsyncWillForwardArgumentsAsStringToUnderlyingClient(): void + { + $client = $this->createMock(StreamingClient::class); + $client->expects($this->once())->method('callAsync')->with('ZCOUNT', 'foo', '-INF', 'INF')->willReturn(new Promise(function () { })); + + $this->factory->expects($this->once())->method('createClient')->willReturn(\React\Promise\resolve($client)); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->never())->method('addTimer'); + assert($loop instanceof LoopInterface); + Loop::set($loop); + + $this->redis->callAsync('ZCOUNT', 'foo', -INF, INF); + } + + public function testSetWithInvalidBoolArgumentThrows(): void + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument must be of type string|int|float, boolean given'); + $this->redis->set('foo', true); + } + + public function testSetWithInvalidObjectArgumentThrows(): void + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument must be of type string|int|float, stdClass given'); + $this->redis->set('foo', (object) []); + } + + public function testCallAsyncWithInvalidBoolArgumentThrows(): void + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument must be of type string|int|float, boolean given'); + $this->redis->callAsync('SET', 'foo', true); // @phpstan-ignore-line + } + + public function testCallAsyncWithInvalidObjectArgumentThrows(): void + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument must be of type string|int|float, stdClass given'); + $this->redis->callAsync('SET', 'foo', (object) []); // @phpstan-ignore-line + } + public function testCloseWillEmitCloseEventWithoutCreatingUnderlyingClient(): void { $this->factory->expects($this->never())->method('createClient');