Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically convert numeric arguments passed to any Redis commands #171

Merged
merged 1 commit into from
Feb 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -417,7 +417,7 @@ $redis = new Clue\React\Redis\RedisClient('localhost', $connector);

#### __call()

The `__call(string $name, string[] $args): PromiseInterface<mixed>` method can be used to
The `__call(string $name, list<string|int|float> $args): PromiseInterface<mixed>` 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.
Expand All @@ -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.

Expand All @@ -451,9 +451,12 @@ that eventually *fulfills* with its *results* on success or *rejects* with an

#### callAsync()

The `callAsync(string $command, string ...$args): PromiseInterface<mixed>` method can be used to
The `callAsync(string $command, string|int|float ...$args): PromiseInterface<mixed>` 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;
Expand All @@ -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);
Expand Down
31 changes: 20 additions & 11 deletions src/RedisClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string|int|float> $args
* @return PromiseInterface<mixed>
* @see self::callAsync()
*/
Expand Down Expand Up @@ -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);
Expand All @@ -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<mixed>
* @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)',
Expand Down
58 changes: 58 additions & 0 deletions tests/RedisClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down