diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a4b35ef4..6eb23b8f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,7 +43,7 @@ jobs: run: | # Mapping of original redis versions to client test containers declare -A redis_clients_version_mapping=( - ["8.12"]="unstable-35791556614-debian" + ["8.12"]="8.12-m02-int" ["8.10"]="8.10.0" ["8.8"]="8.8.0" ["8.6"]="8.6.1" diff --git a/CHANGELOG.md b/CHANGELOG.md index d62a1bfe..495b3475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added - Added optional `CELL_SIZE` argument for `CMS.INITBYDIM` and `CMS.INITBYPROB` +- Added `BLESS GET`, `BLESS SET`, `BLESS CLEAR` and `BLESS SCAN` commands (`$client->bless->get($key)`, `->set($key, $flag)`, `->clear($key, $flag)`, `->scan($cursor, $flag, ['COUNT' => $count])`) ### Changed ### Fixed - Fixed client iteration with master/slave and Sentinel replication (#923) diff --git a/src/ClientContextInterface.php b/src/ClientContextInterface.php index 9829e335..c756330d 100644 --- a/src/ClientContextInterface.php +++ b/src/ClientContextInterface.php @@ -42,6 +42,7 @@ use Predis\Command\Argument\TimeSeries\RangeArguments; use Predis\Command\Argument\TimeSeries\ReadArguments; use Predis\Command\CommandInterface; use Predis\Command\Container\ACL; +use Predis\Command\Container\BLESS; use Predis\Command\Container\CLIENT; use Predis\Command\Container\FUNCTIONS; use Predis\Command\Container\HIMPORT; @@ -431,6 +432,7 @@ use Predis\Command\Redis\VADD; * @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 BLESS $bless * @property CLIENT $client * @property HIMPORT $himport * @property HOTKEYS $hotkeys diff --git a/src/ClientInterface.php b/src/ClientInterface.php index ccdce375..76900172 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -42,6 +42,7 @@ use Predis\Command\Argument\TimeSeries\RangeArguments; use Predis\Command\Argument\TimeSeries\ReadArguments; use Predis\Command\CommandInterface; use Predis\Command\Container\ACL; +use Predis\Command\Container\BLESS; use Predis\Command\Container\CLIENT; use Predis\Command\Container\FUNCTIONS; use Predis\Command\Container\HIMPORT; @@ -446,6 +447,7 @@ use Predis\Response\Status; * @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 BLESS $bless * @property CLIENT $client * @property HIMPORT $himport * @property HOTKEYS $hotkeys diff --git a/src/Cluster/ClusterStrategy.php b/src/Cluster/ClusterStrategy.php index 547deae5..50879b3a 100644 --- a/src/Cluster/ClusterStrategy.php +++ b/src/Cluster/ClusterStrategy.php @@ -214,6 +214,9 @@ abstract class ClusterStrategy implements StrategyInterface 'XSETID' => $getKeyFromFirstArgument, 'XTRIM' => $getKeyFromFirstArgument, + /* commands operating on blessings */ + 'BLESS' => [$this, 'getKeyFromBlessCommands'], + /* commands operating on vector sets */ 'VADD' => $getKeyFromFirstArgument, 'VCARD' => $getKeyFromFirstArgument, @@ -482,6 +485,28 @@ abstract class ClusterStrategy implements StrategyInterface return $arguments[1]; } + /** + * Extracts the key from BLESS commands. + * + * BLESS GET/SET/CLEAR operate on a key at position 1, after the subcommand. + * BLESS SCAN takes a cursor and a flag instead of a key, so it has no slot + * and this returns null. + * + * @param CommandInterface $command Command instance. + * + * @return string|null + */ + protected function getKeyFromBlessCommands(CommandInterface $command) + { + $arguments = $command->getArguments(); + + if (isset($arguments[0], $arguments[1]) && in_array(strtoupper($arguments[0]), ['GET', 'SET', 'CLEAR'], true)) { + return $arguments[1]; + } + + return null; + } + /** * Extracts the key from HIMPORT commands. * diff --git a/src/Command/Container/BLESS.php b/src/Command/Container/BLESS.php new file mode 100644 index 00000000..bb3347e4 --- /dev/null +++ b/src/Command/Container/BLESS.php @@ -0,0 +1,42 @@ + 10]. + * + * @param string|int $cursor Cursor returned by the previous call, 0 to start a new iteration. + * @param string $flag Flag to scan for. + * @param array|null $options Scan options, supports COUNT. + * @return array|mixed [next cursor, list of keys]; an error response when the client runs with exceptions disabled + */ + public function scan($cursor, string $flag, ?array $options = null) + { + return $this->__call('SCAN', func_get_args()); + } +} diff --git a/src/Command/Redis/BLESS.php b/src/Command/Redis/BLESS.php new file mode 100644 index 00000000..50516d4f --- /dev/null +++ b/src/Command/Redis/BLESS.php @@ -0,0 +1,99 @@ +setScanArguments($arguments); + } else { + parent::setArguments($arguments); + } + } + + /** + * BLESS SCAN cursor flag [COUNT count]. + * + * Mirrors the plain SCAN command: options are passed as an array + * (e.g. ['COUNT' => 10]) and expanded into Redis modifiers. + * + * @param array $arguments + * @return void + */ + private function setScanArguments(array $arguments): void + { + if (count($arguments) === 4) { + $options = array_pop($arguments); + if (is_array($options)) { + $options = $this->prepareScanOptions($options); + $arguments = array_merge($arguments, $options); + } + } + + parent::setArguments($arguments); + } + + /** + * Returns a list of SCAN options and modifiers compatible with Redis. + * + * @param array $options List of options. + * @return array + */ + private function prepareScanOptions(array $options): array + { + $options = array_change_key_case($options, CASE_UPPER); + $normalized = []; + + if (!empty($options['COUNT'])) { + $normalized[] = 'COUNT'; + $normalized[] = $options['COUNT']; + } + + return $normalized; + } + + public function prefixKeys($prefix) + { + $arguments = $this->getArguments(); + + // BLESS GET/SET/CLEAR operate on a key at index 1, right after the subcommand. + // BLESS SCAN takes a cursor and a flag, never a key. + if (isset($arguments[0], $arguments[1]) && in_array(strtoupper($arguments[0]), self::KEYED_SUBCOMMANDS, true)) { + $arguments[1] = $prefix . $arguments[1]; + $this->setRawArguments($arguments); + } + } +} diff --git a/tests/Predis/Cluster/PredisStrategyTest.php b/tests/Predis/Cluster/PredisStrategyTest.php index 5b2d5ded..2f00af8e 100644 --- a/tests/Predis/Cluster/PredisStrategyTest.php +++ b/tests/Predis/Cluster/PredisStrategyTest.php @@ -257,6 +257,7 @@ class PredisStrategyTest extends PredisTestCase $arguments = [ 'XGROUP' => ['CREATE', 'key', 'group', '$'], 'XINFO' => ['GROUPS', 'key'], + 'BLESS' => ['GET', 'key'], ]; foreach ($this->getExpectedCommands('keys-stream-subcommand') as $commandID) { @@ -773,6 +774,9 @@ class PredisStrategyTest extends PredisTestCase 'XSETID' => 'keys-first', 'XTRIM' => 'keys-first', + /* commands operating on blessings */ + 'BLESS' => 'keys-stream-subcommand', + /* commands operating on vector sets */ 'VADD' => 'keys-vector', 'VCARD' => 'keys-first', diff --git a/tests/Predis/Cluster/RedisStrategyTest.php b/tests/Predis/Cluster/RedisStrategyTest.php index fd12ec6b..d0e5134f 100644 --- a/tests/Predis/Cluster/RedisStrategyTest.php +++ b/tests/Predis/Cluster/RedisStrategyTest.php @@ -240,6 +240,7 @@ class RedisStrategyTest extends PredisTestCase $arguments = [ 'XGROUP' => ['CREATE', 'key', 'group', '$'], 'XINFO' => ['GROUPS', 'key'], + 'BLESS' => ['GET', 'key'], ]; foreach ($this->getExpectedCommands('keys-stream-subcommand') as $commandID) { @@ -793,6 +794,9 @@ class RedisStrategyTest extends PredisTestCase 'XSETID' => 'keys-first', 'XTRIM' => 'keys-first', + /* commands operating on blessings */ + 'BLESS' => 'keys-stream-subcommand', + /* commands operating on vector sets */ 'VADD' => 'keys-vector', 'VCARD' => 'keys-first', diff --git a/tests/Predis/Command/Redis/BLESS_Test.php b/tests/Predis/Command/Redis/BLESS_Test.php new file mode 100644 index 00000000..18fa4f35 --- /dev/null +++ b/tests/Predis/Command/Redis/BLESS_Test.php @@ -0,0 +1,290 @@ +getCommand(); + $command->setArguments($actualArguments); + + $this->assertSame($expectedArguments, $command->getArguments()); + } + + /** + * @group disconnected + */ + public function testParseResponse(): void + { + $this->assertSame([], $this->getCommand()->parseResponse([])); + $this->assertSame(['a', 'b'], $this->getCommand()->parseResponse(['a', 'b'])); + } + + /** + * @group disconnected + * @dataProvider prefixKeysProvider + */ + public function testPrefixKeys(array $arguments, array $expectedArguments): void + { + $command = $this->getCommand(); + $command->setArguments($arguments); + $command->prefixKeys('prefix:'); + + $this->assertSame($expectedArguments, $command->getArguments()); + } + + /** + * @group disconnected + * @dataProvider containerProvider + */ + public function testContainerBuildsSubcommand(string $method, array $methodArguments, array $expectedArguments, $response): void + { + $client = $this->getMockBuilder('Predis\ClientInterface')->getMock(); + $command = $this->getCommand(); + + // The container forwards the subcommand followed by the raw method arguments; + // argument normalisation (e.g. COUNT expansion) happens inside the command. + $rawArguments = array_merge([strtoupper($method)], $methodArguments); + + $client + ->expects($this->once()) + ->method('createCommand') + ->with('BLESS', $rawArguments) + ->willReturnCallback(function () use ($command, $rawArguments) { + $command->setArguments($rawArguments); + + return $command; + }); + + $client + ->expects($this->once()) + ->method('executeCommand') + ->with($command) + ->willReturn($response); + + $container = new Container($client); + + $this->assertSame('BLESS', $container->getContainerCommandId()); + $this->assertSame($response, $container->$method(...$methodArguments)); + $this->assertSame($expectedArguments, $command->getArguments()); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.12.0 + */ + public function testSetGetScanAndClearFlags(): void + { + $this->assertBlessRoundTrip($this->getClient()); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.12.0 + */ + public function testSetGetScanAndClearFlagsResp3(): void + { + $this->assertBlessRoundTrip($this->getResp3Client()); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.12.0 + */ + public function testThrowsExceptionOnMissingKey(): void + { + $redis = $this->getClient(); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage('ERR no such key'); + + $redis->bless->get('missing'); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.12.0 + */ + public function testThrowsExceptionOnUnknownFlag(): void + { + $redis = $this->getClient(); + $redis->set('foo', 'bar'); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage('ERR syntax error'); + + $redis->bless->set('foo', 'BOGUS'); + } + + private function assertBlessRoundTrip($redis): void + { + $redis->set('foo', 'bar'); + $redis->set('baz', 'qux'); + $redis->set('plain', 'value'); + + $this->assertSame([], $redis->bless->get('foo')); + + $this->assertSame(1, $redis->bless->set('foo', 'NO-EVICT')); + $this->assertSame(0, $redis->bless->set('foo', 'NO-EVICT')); + $this->assertSame(1, $redis->bless->set('baz', 'NO-EVICT')); + + $this->assertSame(['NO-EVICT'], $redis->bless->get('foo')); + $this->assertSame([], $redis->bless->get('plain')); + + // Full scan in one call. + [$cursor, $keys] = $redis->bless->scan(0, 'NO-EVICT'); + $this->assertSame('0', $cursor); + $this->assertEqualsCanonicalizing(['foo', 'baz'], $keys); + + // Iterating with COUNT must eventually visit every flagged key (duplicates are allowed, as with SCAN). + $cursor = 0; + $seen = []; + do { + [$cursor, $keys] = $redis->bless->scan($cursor, 'NO-EVICT', ['COUNT' => 1]); + $seen = array_merge($seen, $keys); + } while ($cursor !== '0'); + $this->assertEqualsCanonicalizing(['foo', 'baz'], array_unique($seen)); + + $this->assertSame(1, $redis->bless->clear('foo', 'NO-EVICT')); + $this->assertSame(0, $redis->bless->clear('foo', 'NO-EVICT')); + $this->assertSame([], $redis->bless->get('foo')); + + [$cursor, $keys] = $redis->bless->scan(0, 'NO-EVICT'); + $this->assertSame('0', $cursor); + $this->assertSame(['baz'], $keys); + } + + public function argumentsProvider(): array + { + return [ + 'with GET subcommand' => [ + ['GET', 'key'], + ['GET', 'key'], + ], + 'with SET subcommand' => [ + ['SET', 'key', 'flag'], + ['SET', 'key', 'flag'], + ], + 'with CLEAR subcommand' => [ + ['CLEAR', 'key', 'flag'], + ['CLEAR', 'key', 'flag'], + ], + 'with lowercase subcommand' => [ + ['get', 'key'], + ['get', 'key'], + ], + 'with SCAN subcommand' => [ + ['SCAN', 0, 'flag'], + ['SCAN', 0, 'flag'], + ], + 'with SCAN subcommand and COUNT option' => [ + ['SCAN', 0, 'flag', ['COUNT' => 10]], + ['SCAN', 0, 'flag', 'COUNT', 10], + ], + 'with SCAN subcommand and lowercase option key' => [ + ['SCAN', '17', 'flag', ['count' => 10]], + ['SCAN', '17', 'flag', 'COUNT', 10], + ], + 'with SCAN subcommand and empty options' => [ + ['SCAN', 0, 'flag', []], + ['SCAN', 0, 'flag'], + ], + 'with SCAN subcommand and unknown option' => [ + ['SCAN', 0, 'flag', ['MATCH' => 'x*']], + ['SCAN', 0, 'flag'], + ], + 'with lowercase SCAN subcommand and COUNT option' => [ + ['scan', 0, 'flag', ['COUNT' => 10]], + ['scan', 0, 'flag', 'COUNT', 10], + ], + 'with SCAN subcommand and null options' => [ + ['SCAN', 0, 'flag', null], + ['SCAN', 0, 'flag'], + ], + 'with SCAN subcommand and raw modifiers' => [ + ['SCAN', 0, 'flag', 'COUNT', 10], + ['SCAN', 0, 'flag', 'COUNT', 10], + ], + ]; + } + + public function prefixKeysProvider(): array + { + return [ + 'with GET subcommand' => [ + ['GET', 'key'], + ['GET', 'prefix:key'], + ], + 'with SET subcommand' => [ + ['SET', 'key', 'flag'], + ['SET', 'prefix:key', 'flag'], + ], + 'with CLEAR subcommand' => [ + ['CLEAR', 'key', 'flag'], + ['CLEAR', 'prefix:key', 'flag'], + ], + 'with lowercase subcommand' => [ + ['set', 'key', 'flag'], + ['set', 'prefix:key', 'flag'], + ], + 'with keyed subcommand and no key' => [ + ['GET'], + ['GET'], + ], + 'with SCAN subcommand (cursor is not a key)' => [ + ['SCAN', 0, 'flag', ['COUNT' => 10]], + ['SCAN', 0, 'flag', 'COUNT', 10], + ], + ]; + } + + public function containerProvider(): array + { + return [ + 'get' => ['get', ['key'], ['GET', 'key'], []], + 'set' => ['set', ['key', 'flag'], ['SET', 'key', 'flag'], 1], + 'clear' => ['clear', ['key', 'flag'], ['CLEAR', 'key', 'flag'], 1], + 'scan' => ['scan', [0, 'flag'], ['SCAN', 0, 'flag'], ['0', ['key1', 'key2']]], + 'scan with count' => ['scan', [0, 'flag', ['COUNT' => 10]], ['SCAN', 0, 'flag', 'COUNT', 10], ['0', ['key1']]], + 'scan with explicit null options' => ['scan', [0, 'flag', null], ['SCAN', 0, 'flag'], ['0', ['key1', 'key2']]], + ]; + } +}