feat: bless commands family (#1733)

* feat: bless commands family

* fix: bless scan args processing
This commit is contained in:
Vladislav Kotsev
2026-10-06 14:36:19 +03:00
committed by GitHub
parent a33f9f85c2
commit c270c2d24e
10 changed files with 470 additions and 1 deletions
+1 -1
View File
@@ -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"
+1
View File
@@ -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)
+2
View File
@@ -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
+2
View File
@@ -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
+25
View File
@@ -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.
*
+42
View File
@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Predis package.
*
* (c) 2009-2020 Daniele Alessandri
* (c) 2021-2026 Till Krüss
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Predis\Command\Container;
/**
* @method array get(string $key) Returns the flags currently set on the key.
* @method int set(string $key, string $flag) Returns 1 if the flag was added, 0 if it was already set.
* @method int clear(string $key, string $flag) Returns 1 if the flag was removed, 0 if it was not set.
*/
class BLESS extends AbstractContainer
{
public function getContainerCommandId(): string
{
return 'BLESS';
}
/**
* Iterates over the keys carrying the given flag.
*
* Mirrors the plain SCAN command signature: options are passed as an
* array, e.g. ['COUNT' => 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());
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
/*
* This file is part of the Predis package.
*
* (c) 2009-2020 Daniele Alessandri
* (c) 2021-2026 Till Krüss
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Predis\Command\Redis;
use Predis\Command\PrefixableCommand as RedisCommand;
/**
* @see https://redis.io/commands/?name=bless
*
* Container command corresponds to any BLESS *.
* Represents any BLESS command with subcommand as first argument.
*/
class BLESS extends RedisCommand
{
private const KEYED_SUBCOMMANDS = ['GET', 'SET', 'CLEAR'];
/**
* {@inheritdoc}
*/
public function getId()
{
return 'BLESS';
}
/**
* {@inheritdoc}
*/
public function setArguments(array $arguments)
{
if (isset($arguments[0]) && strtoupper($arguments[0]) === 'SCAN') {
$this->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);
}
}
}
@@ -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',
@@ -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',
+290
View File
@@ -0,0 +1,290 @@
<?php
/*
* This file is part of the Predis package.
*
* (c) 2009-2020 Daniele Alessandri
* (c) 2021-2026 Till Krüss
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Predis\Command\Redis;
use Predis\Command\Container\BLESS as Container;
use Predis\Response\ServerException;
/**
* @group commands
* @group realm-generic
*/
class BLESS_Test extends PredisCommandTestCase
{
/**
* {@inheritdoc}
*/
protected function getExpectedCommand(): string
{
return BLESS::class;
}
/**
* {@inheritdoc}
*/
protected function getExpectedId(): string
{
return 'BLESS';
}
/**
* @group disconnected
* @dataProvider argumentsProvider
*/
public function testFilterArguments(array $actualArguments, array $expectedArguments): void
{
$command = $this->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']]],
];
}
}