From adcf3c7d39f4166bc860ba41b0891f14d76f08c8 Mon Sep 17 00:00:00 2001 From: Aleksey Tupichenkov Date: Mon, 20 Oct 2025 17:46:37 +0300 Subject: [PATCH 1/9] feat: update schema context bundle --- composer.json | 2 +- src/Redis/SchemaAwareRedisAdapter.php | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index b44296d..010376e 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ }, "require": { "php": ">=8.3", - "macpaw/schema-context-bundle": "^1.1", + "macpaw/schema-context-bundle": "^2.0", "symfony/cache": "^6.4|^7.0" }, "require-dev": { diff --git a/src/Redis/SchemaAwareRedisAdapter.php b/src/Redis/SchemaAwareRedisAdapter.php index e8242e5..cdfe075 100644 --- a/src/Redis/SchemaAwareRedisAdapter.php +++ b/src/Redis/SchemaAwareRedisAdapter.php @@ -24,7 +24,7 @@ private function prefixKey(string $key): string { $schema = $this->resolver->getSchema(); - return $schema ? $schema . '.' . $key : $key; + return $schema === $this->resolver->getEnvironmentSchema() ? $key : $schema . '.' . $key; } public function clear(string $prefix = ''): bool @@ -98,6 +98,8 @@ public function delete(string $key): bool public function reset(): void { - $this->decorated->clear($this->resolver->getSchema() ?? ''); + $schema = $this->resolver->getSchema(); + + $this->decorated->clear($schema === $this->resolver->getEnvironmentSchema() ? '' : $schema); } } From c224fad248f7b1b46cab7e083dfb2be27be22a89 Mon Sep 17 00:00:00 2001 From: Serhii Donii Date: Tue, 11 Nov 2025 16:43:47 +0200 Subject: [PATCH 2/9] feat(PLATECO-2706): decorate predis client --- .gitignore | 1 + README.md | 14 +- composer.json | 6 +- src/Redis/RedisClientAdapter.php | 107 ++++++++++++++ src/Redis/SchemaAwareRedisAdapter.php | 19 ++- tests/Redis/RedisClientAdapterTest.php | 193 +++++++++++++++++++++++++ tests/Stub/TestRedisClient.php | 106 ++++++++++++++ 7 files changed, 437 insertions(+), 9 deletions(-) create mode 100644 src/Redis/RedisClientAdapter.php create mode 100644 tests/Redis/RedisClientAdapterTest.php create mode 100644 tests/Stub/TestRedisClient.php diff --git a/.gitignore b/.gitignore index 1473148..0cab60f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ /phpunit.xml .phpunit.result.cache ###< phpunit/phpunit ### +/clover.xml diff --git a/README.md b/README.md index 28b75ad..834570b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,19 @@ services: - '@Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver' ``` +Decorate predis client with symfony-bundles/redis-bundle: + +```yaml +# config/services.yaml + +services: + Macpaw\RedisSchemaBundle\Redis\RedisClientAdapter: + decorates: SymfonyBundles\RedisBundle\Redis\ClientInterface + arguments: + - '@.inner' + - '@Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver' +``` + ## Usage Anywhere you use Symfony cache services (injected via CacheInterface or AdapterInterface), your keys will automatically be prefixed based on the current schema. For example: @@ -76,4 +89,3 @@ Feel free to open issues and submit pull requests. ## License This bundle is released under the MIT license. - diff --git a/composer.json b/composer.json index b44296d..8a16816 100644 --- a/composer.json +++ b/composer.json @@ -21,13 +21,17 @@ "require-dev": { "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^10.0", - "squizlabs/php_codesniffer": "3.7.*" + "squizlabs/php_codesniffer": "3.7.*", + "symfony-bundles/redis-bundle": "^3.1" }, "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true } }, + "suggest": { + "symfony-bundles/redis-bundle": "For predis client key decarations with schema prefix" + }, "scripts": { "composer-validate": [ "composer validate" diff --git a/src/Redis/RedisClientAdapter.php b/src/Redis/RedisClientAdapter.php new file mode 100644 index 0000000..6fa405d --- /dev/null +++ b/src/Redis/RedisClientAdapter.php @@ -0,0 +1,107 @@ + + */ + private readonly array $decoratedCallMethods = [], + ) { + } + + public function pop(string $key): ?string + { + return $this->decorated->pop($this->prefixKey($key)); + } + + public function push(string $key, ...$values): int + { + return $this->decorated->push($this->prefixKey($key), ...$values); + } + + public function count(string $key): int + { + return $this->decorated->count($this->prefixKey($key)); + } + + public function remove(string $key): int + { + return $this->decorated->remove($this->prefixKey($key)); + } + + public function getCommandFactory(): FactoryInterface + { + return $this->decorated->getCommandFactory(); + } + + public function getOptions(): OptionsInterface + { + return $this->decorated->getOptions(); + } + + public function connect(): void + { + $this->decorated->connect(); + } + + public function disconnect(): void + { + $this->decorated->disconnect(); + } + + public function getConnection(): ConnectionInterface + { + return $this->decorated->getConnection(); + } + + public function createCommand($method, $arguments = []): CommandInterface + { + return $this->decorated->createCommand($method, $arguments); + } + + public function executeCommand(CommandInterface $command): mixed + { + return $this->decorated->executeCommand($command); + } + + public function __call($method, $arguments): mixed + { + if (in_array($method, $this->decoratedCallMethods, true)) { + // Automatically prefix the first argument if it's a string + if (array_key_exists(0, $arguments) && is_scalar($arguments[0])) { + $arguments[0] = $this->prefixKey((string) $arguments[0]); + } + } + + return $this->decorated->$method(...$arguments); + } + + private function prefixKey(string $key): string + { + $schema = $this->resolver->getSchema(); + + if ($this->defaultSchema !== null && $schema === $this->defaultSchema) { + return $key; + } + + return $schema ? $schema . '.' . $key : $key; + } +} diff --git a/src/Redis/SchemaAwareRedisAdapter.php b/src/Redis/SchemaAwareRedisAdapter.php index e8242e5..17e65c7 100644 --- a/src/Redis/SchemaAwareRedisAdapter.php +++ b/src/Redis/SchemaAwareRedisAdapter.php @@ -17,16 +17,10 @@ class SchemaAwareRedisAdapter implements AdapterInterface, CacheInterface, Reset public function __construct( private readonly RedisAdapter $decorated, private readonly BaggageSchemaResolver $resolver, + private readonly ?string $defaultSchema = null, ) { } - private function prefixKey(string $key): string - { - $schema = $this->resolver->getSchema(); - - return $schema ? $schema . '.' . $key : $key; - } - public function clear(string $prefix = ''): bool { return $this->decorated->clear($prefix); @@ -100,4 +94,15 @@ public function reset(): void { $this->decorated->clear($this->resolver->getSchema() ?? ''); } + + private function prefixKey(string $key): string + { + $schema = $this->resolver->getSchema(); + + if ($this->defaultSchema !== null && $schema === $this->defaultSchema) { + return $key; + } + + return $schema ? $schema . '.' . $key : $key; + } } diff --git a/tests/Redis/RedisClientAdapterTest.php b/tests/Redis/RedisClientAdapterTest.php new file mode 100644 index 0000000..305c5ca --- /dev/null +++ b/tests/Redis/RedisClientAdapterTest.php @@ -0,0 +1,193 @@ +resolver = $this->createMock(BaggageSchemaResolver::class); + } + + public function testPopPrefixesKeyWithSchema(): void + { + $this->resolver->method('getSchema')->willReturn('test'); + $client = new TestRedisClient(); + $client->popReturn = 'value'; + + $adapter = new RedisClientAdapter($this->resolver, $client); + + self::assertSame('value', $adapter->pop('mykey')); + self::assertSame([ + ['method' => 'pop', 'args' => ['test.mykey']], + ], $client->calls); + } + + public function testPushPrefixesKeyAndForwardsValues(): void + { + $this->resolver->method('getSchema')->willReturn('schem'); + $client = new TestRedisClient(); + $client->pushReturn = 3; + + $adapter = new RedisClientAdapter($this->resolver, $client); + + self::assertSame(3, $adapter->push('list', 'a', 2, true)); + self::assertSame([ + ['method' => 'push', 'args' => ['schem.list', 'a', 2, true]], + ], $client->calls); + } + + public function testCountAndRemovePrefixKeys(): void + { + $this->resolver->method('getSchema')->willReturn('ns'); + $client = new TestRedisClient(); + $client->countReturn = 5; + $client->removeReturn = 1; + + $adapter = new RedisClientAdapter($this->resolver, $client); + + self::assertSame(5, $adapter->count('queue')); + self::assertSame(1, $adapter->remove('to_remove')); + self::assertSame([ + ['method' => 'count', 'args' => ['ns.queue']], + ['method' => 'remove', 'args' => ['ns.to_remove']], + ], $client->calls); + } + + public function testDefaultSchemaSkipsPrefixing(): void + { + $this->resolver->method('getSchema')->willReturn('prod'); + $client = new TestRedisClient(); + + $adapter = new RedisClientAdapter($this->resolver, $client, 'prod'); + + self::assertNull($adapter->pop('plain')); + self::assertSame([ + ['method' => 'pop', 'args' => ['plain']], + ], $client->calls); + } + + public function testNoSchemaReturnsKeyAsIs(): void + { + $this->resolver->method('getSchema')->willReturn(null); + $client = new TestRedisClient(); + $client->pushReturn = 1; + + $adapter = new RedisClientAdapter($this->resolver, $client); + + self::assertSame(1, $adapter->push('name', 'x')); + self::assertSame([ + ['method' => 'push', 'args' => ['name', 'x']], + ], $client->calls); + } + + public function testPassThroughClientMethods(): void + { + $client = new TestRedisClient(); + $factory = $this->createMock(PredisFactoryInterface::class); + $options = $this->createMock(PredisOptionsInterface::class); + $connection = $this->createMock(PredisConnectionInterface::class); + $command = $this->createMock(PredisCommandInterface::class); + + $client->factory = $factory; + $client->options = $options; + $client->connection = $connection; + $client->createCommandReturn = $command; + $client->executeCommandReturn = 'PONG'; + + $adapter = new RedisClientAdapter($this->resolver, $client); + + self::assertSame($factory, $adapter->getCommandFactory()); + self::assertSame($options, $adapter->getOptions()); + self::assertSame($connection, $adapter->getConnection()); + self::assertSame($command, $adapter->createCommand('ping', [1])); + self::assertSame('PONG', $adapter->executeCommand($command)); + + $adapter->connect(); + $adapter->disconnect(); + + self::assertTrue($client->connected); + self::assertTrue($client->disconnected); + } + + public function testMagicCallPrefixesFirstArgumentWhenScalar(): void + { + $this->resolver->method('getSchema')->willReturn('s'); + $client = new TestRedisClient(); + $client->dynamicHandlers['xhmget'] = function (array $args) { + return ['value1', 'value2']; + }; + + $adapter = new RedisClientAdapter($this->resolver, $client, null, ['xhmget']); + + $result = $adapter->xhmget('hash', 'field1', 'field2'); + self::assertSame(['value1', 'value2'], $result); + self::assertSame([ + ['method' => 'xhmget', 'args' => ['s.hash', 'field1', 'field2']], + ], $client->calls); + } + + public function testMagicCallDoesNotPrefixWhenFirstArgumentNotScalar(): void + { + $this->resolver->method('getSchema')->willReturn('s'); + $client = new TestRedisClient(); + $client->dynamicHandlers['xhmget'] = function (array $args) { + return ['v']; + }; + + $adapter = new RedisClientAdapter($this->resolver, $client, null, ['xhmget']); + + $firstArg = ['hash']; + $result = $adapter->xhmget($firstArg, 'field'); + self::assertSame(['v'], $result); + self::assertSame([ + ['method' => 'xhmget', 'args' => [$firstArg, 'field']], + ], $client->calls); + } + + public function testMagicCallWhenMethodNotInDecoratedListDoesNotPrefix(): void + { + $this->resolver->method('getSchema')->willReturn('prefix'); + $client = new TestRedisClient(); + $client->dynamicHandlers['custom'] = fn(array $args) => 'ok'; + + // Not listing 'custom' in decoratedCallMethods, so it should not prefix the key + $adapter = new RedisClientAdapter($this->resolver, $client, null, ['xhmget']); + + $result = $adapter->custom('key', 'arg'); + self::assertSame('ok', $result); + self::assertSame([ + ['method' => 'custom', 'args' => ['key', 'arg']], + ], $client->calls); + } + + public function testMagicCallWhenMethodHasNoArguments(): void + { + $this->resolver->method('getSchema')->willReturn('pref'); + $client = new TestRedisClient(); + $client->dynamicHandlers['xping'] = fn(array $args) => 'PONG'; + + $adapter = new RedisClientAdapter($this->resolver, $client, null, ['xping']); + + $result = $adapter->xping(); + self::assertSame('PONG', $result); + self::assertSame([ + ['method' => 'xping', 'args' => []], + ], $client->calls); + } +} diff --git a/tests/Stub/TestRedisClient.php b/tests/Stub/TestRedisClient.php new file mode 100644 index 0000000..2d08d95 --- /dev/null +++ b/tests/Stub/TestRedisClient.php @@ -0,0 +1,106 @@ + */ + public array $dynamicHandlers = []; + + public function pop(string $key): ?string + { + $this->calls[] = ['method' => 'pop', 'args' => [$key]]; + return $this->popReturn; + } + + public function push(string $key, ...$values): int + { + $this->calls[] = ['method' => 'push', 'args' => array_merge([$key], $values)]; + return $this->pushReturn; + } + + public function count(string $key): int + { + $this->calls[] = ['method' => 'count', 'args' => [$key]]; + return $this->countReturn; + } + + public function remove(string $key): int + { + $this->calls[] = ['method' => 'remove', 'args' => [$key]]; + return $this->removeReturn; + } + + public function getCommandFactory(): FactoryInterface + { + return $this->factory ?? throw new \RuntimeException('factory not set'); + } + + public function getOptions(): OptionsInterface + { + return $this->options ?? throw new \RuntimeException('options not set'); + } + + public function connect(): void + { + $this->connected = true; + $this->calls[] = ['method' => 'connect', 'args' => []]; + } + + public function disconnect(): void + { + $this->disconnected = true; + $this->calls[] = ['method' => 'disconnect', 'args' => []]; + } + + public function getConnection(): ConnectionInterface + { + return $this->connection ?? throw new \RuntimeException('connection not set'); + } + + public function createCommand($method, $arguments = []): CommandInterface + { + $this->calls[] = ['method' => 'createCommand', 'args' => [$method, $arguments]]; + return $this->createCommandReturn ?? throw new \RuntimeException('createCommandReturn not set'); + } + + public function executeCommand(CommandInterface $command): mixed + { + $this->calls[] = ['method' => 'executeCommand', 'args' => [$command]]; + return $this->executeCommandReturn; + } + + public function __call($name, $arguments) + { + $this->calls[] = ['method' => $name, 'args' => $arguments]; + if (isset($this->dynamicHandlers[$name])) { + return ($this->dynamicHandlers[$name])($arguments); + } + throw new \BadMethodCallException("No handler for dynamic method {$name}"); + } +} From 0b0d4f25ba3f9469ba49172d8bbd2c6046e087b1 Mon Sep 17 00:00:00 2001 From: Serhii Donii Date: Tue, 11 Nov 2025 17:09:13 +0200 Subject: [PATCH 3/9] feat(PLATECO-2706): decorate predis client --- src/Redis/RedisClientAdapter.php | 6 ++++++ tests/Redis/RedisClientAdapterTest.php | 29 ++++++++++++++------------ tests/Stub/TestRedisClient.php | 17 +++++++++++---- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/Redis/RedisClientAdapter.php b/src/Redis/RedisClientAdapter.php index 6fa405d..75f46d8 100644 --- a/src/Redis/RedisClientAdapter.php +++ b/src/Redis/RedisClientAdapter.php @@ -72,6 +72,9 @@ public function getConnection(): ConnectionInterface return $this->decorated->getConnection(); } + /** + * @param array $arguments + */ public function createCommand($method, $arguments = []): CommandInterface { return $this->decorated->createCommand($method, $arguments); @@ -82,6 +85,9 @@ public function executeCommand(CommandInterface $command): mixed return $this->decorated->executeCommand($command); } + /** + * @param array $arguments + */ public function __call($method, $arguments): mixed { if (in_array($method, $this->decoratedCallMethods, true)) { diff --git a/tests/Redis/RedisClientAdapterTest.php b/tests/Redis/RedisClientAdapterTest.php index 305c5ca..0dcb103 100644 --- a/tests/Redis/RedisClientAdapterTest.php +++ b/tests/Redis/RedisClientAdapterTest.php @@ -9,8 +9,10 @@ use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Predis\Command\CommandInterface; use Predis\Command\CommandInterface as PredisCommandInterface; use Predis\Command\FactoryInterface as PredisFactoryInterface; +use Predis\Command\Redis\PING; use Predis\Configuration\OptionsInterface as PredisOptionsInterface; use Predis\Connection\ConnectionInterface as PredisConnectionInterface; @@ -129,16 +131,16 @@ public function testMagicCallPrefixesFirstArgumentWhenScalar(): void { $this->resolver->method('getSchema')->willReturn('s'); $client = new TestRedisClient(); - $client->dynamicHandlers['xhmget'] = function (array $args) { + $client->dynamicHandlers['hmget'] = function (array $args) { return ['value1', 'value2']; }; - $adapter = new RedisClientAdapter($this->resolver, $client, null, ['xhmget']); + $adapter = new RedisClientAdapter($this->resolver, $client, null, ['hmget']); - $result = $adapter->xhmget('hash', 'field1', 'field2'); + $result = $adapter->hmget('hash', ['field1']); self::assertSame(['value1', 'value2'], $result); self::assertSame([ - ['method' => 'xhmget', 'args' => ['s.hash', 'field1', 'field2']], + ['method' => 'hmget', 'args' => ['s.hash', ['field1']]], ], $client->calls); } @@ -146,17 +148,17 @@ public function testMagicCallDoesNotPrefixWhenFirstArgumentNotScalar(): void { $this->resolver->method('getSchema')->willReturn('s'); $client = new TestRedisClient(); - $client->dynamicHandlers['xhmget'] = function (array $args) { + $client->dynamicHandlers['hmget'] = function (array $args) { return ['v']; }; - $adapter = new RedisClientAdapter($this->resolver, $client, null, ['xhmget']); + $adapter = new RedisClientAdapter($this->resolver, $client, null, ['hmget']); - $firstArg = ['hash']; - $result = $adapter->xhmget($firstArg, 'field'); + $firstArg = 'hash'; + $result = $adapter->hmget($firstArg, ['field']); self::assertSame(['v'], $result); self::assertSame([ - ['method' => 'xhmget', 'args' => [$firstArg, 'field']], + ['method' => 'hmget', 'args' => ['s.'.$firstArg, ['field']]], ], $client->calls); } @@ -169,6 +171,7 @@ public function testMagicCallWhenMethodNotInDecoratedListDoesNotPrefix(): void // Not listing 'custom' in decoratedCallMethods, so it should not prefix the key $adapter = new RedisClientAdapter($this->resolver, $client, null, ['xhmget']); + // @phpstan-ignore-next-line $result = $adapter->custom('key', 'arg'); self::assertSame('ok', $result); self::assertSame([ @@ -180,14 +183,14 @@ public function testMagicCallWhenMethodHasNoArguments(): void { $this->resolver->method('getSchema')->willReturn('pref'); $client = new TestRedisClient(); - $client->dynamicHandlers['xping'] = fn(array $args) => 'PONG'; + $client->dynamicHandlers['ping'] = fn(array $args) => 'PONG'; - $adapter = new RedisClientAdapter($this->resolver, $client, null, ['xping']); + $adapter = new RedisClientAdapter($this->resolver, $client, null, ['ping']); - $result = $adapter->xping(); + $result = $adapter->ping(); self::assertSame('PONG', $result); self::assertSame([ - ['method' => 'xping', 'args' => []], + ['method' => 'ping', 'args' => []], ], $client->calls); } } diff --git a/tests/Stub/TestRedisClient.php b/tests/Stub/TestRedisClient.php index 2d08d95..c47049e 100644 --- a/tests/Stub/TestRedisClient.php +++ b/tests/Stub/TestRedisClient.php @@ -4,21 +4,23 @@ namespace Macpaw\RedisSchemaBundle\Tests\Stub; +use Predis\Client; use Predis\Command\CommandInterface; use Predis\Command\FactoryInterface; use Predis\Configuration\OptionsInterface; use Predis\Connection\ConnectionInterface; use SymfonyBundles\RedisBundle\Redis\ClientInterface; -class TestRedisClient implements ClientInterface +class TestRedisClient extends Client implements ClientInterface { + // @phpstan-ignore-next-line public array $calls = []; public ?FactoryInterface $factory = null; public ?OptionsInterface $options = null; public ?ConnectionInterface $connection = null; - public mixed $createCommandReturn = null; + public ?CommandInterface $createCommandReturn = null; public mixed $executeCommandReturn = null; public ?string $popReturn = null; @@ -29,7 +31,7 @@ class TestRedisClient implements ClientInterface public bool $connected = false; public bool $disconnected = false; - /** @var array */ + // @phpstan-ignore-next-line public array $dynamicHandlers = []; public function pop(string $key): ?string @@ -83,6 +85,9 @@ public function getConnection(): ConnectionInterface return $this->connection ?? throw new \RuntimeException('connection not set'); } + /** + * @param array $arguments + */ public function createCommand($method, $arguments = []): CommandInterface { $this->calls[] = ['method' => 'createCommand', 'args' => [$method, $arguments]]; @@ -92,13 +97,17 @@ public function createCommand($method, $arguments = []): CommandInterface public function executeCommand(CommandInterface $command): mixed { $this->calls[] = ['method' => 'executeCommand', 'args' => [$command]]; + return $this->executeCommandReturn; } + /** + * @param array $arguments + */ public function __call($name, $arguments) { $this->calls[] = ['method' => $name, 'args' => $arguments]; - if (isset($this->dynamicHandlers[$name])) { + if (array_key_exists($name, $this->dynamicHandlers)) { return ($this->dynamicHandlers[$name])($arguments); } throw new \BadMethodCallException("No handler for dynamic method {$name}"); From a77ebd1859101608691552352c25a79e98246fa0 Mon Sep 17 00:00:00 2001 From: Serhii Donii Date: Tue, 11 Nov 2025 17:11:16 +0200 Subject: [PATCH 4/9] feat(PLATECO-2706): decorate predis client --- .github/workflows/static-analysis.yaml | 2 +- tests/Redis/RedisClientAdapterTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index 8796ae1..ea1acf4 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -7,7 +7,7 @@ on: jobs: php-cs-fixer: - name: PHP-CS-Fixer + name: PHP Code Sniffer runs-on: ubuntu-latest steps: - name: Checkout diff --git a/tests/Redis/RedisClientAdapterTest.php b/tests/Redis/RedisClientAdapterTest.php index 0dcb103..bfe976f 100644 --- a/tests/Redis/RedisClientAdapterTest.php +++ b/tests/Redis/RedisClientAdapterTest.php @@ -158,7 +158,7 @@ public function testMagicCallDoesNotPrefixWhenFirstArgumentNotScalar(): void $result = $adapter->hmget($firstArg, ['field']); self::assertSame(['v'], $result); self::assertSame([ - ['method' => 'hmget', 'args' => ['s.'.$firstArg, ['field']]], + ['method' => 'hmget', 'args' => ['s.' . $firstArg, ['field']]], ], $client->calls); } From 183d0b7244e84f979288a0322098da8884b7fcdb Mon Sep 17 00:00:00 2001 From: Serhii Donii Date: Tue, 11 Nov 2025 17:31:27 +0200 Subject: [PATCH 5/9] feat(PLATECO-2706): decorate predis client --- src/Redis/RedisClientAdapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Redis/RedisClientAdapter.php b/src/Redis/RedisClientAdapter.php index 75f46d8..b3c6041 100644 --- a/src/Redis/RedisClientAdapter.php +++ b/src/Redis/RedisClientAdapter.php @@ -21,7 +21,7 @@ public function __construct( * Method names that should be prefixed with the schema which not present in interfaces * like a hmget, hset, etc. * - * @var array + * @var array */ private readonly array $decoratedCallMethods = [], ) { From bd7c66b77d76a633de026cc0b9f73200899428c9 Mon Sep 17 00:00:00 2001 From: Serhii Donii Date: Tue, 11 Nov 2025 17:56:55 +0200 Subject: [PATCH 6/9] feat(PLATECO-2706): decorate predis client --- .github/workflows/static-analysis.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index ea1acf4..5edf43a 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -6,7 +6,7 @@ on: branches: [ main, develop ] jobs: - php-cs-fixer: + php-code-sniffer: name: PHP Code Sniffer runs-on: ubuntu-latest steps: From 65ad6aa9911f433e33448877509a0e8cccec0bac Mon Sep 17 00:00:00 2001 From: Serhii Donii Date: Tue, 11 Nov 2025 18:16:50 +0200 Subject: [PATCH 7/9] feat(PLATECO-2706): decorate predis client --- README.md | 2 +- ...dapter.php => SchemaAwarePRedisClient.php} | 2 +- ...st.php => SchemaAwarePRedisClientTest.php} | 24 +++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) rename src/Redis/{RedisClientAdapter.php => SchemaAwarePRedisClient.php} (98%) rename tests/Redis/{RedisClientAdapterTest.php => SchemaAwarePRedisClientTest.php} (87%) diff --git a/README.md b/README.md index 834570b..c8655f1 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Decorate predis client with symfony-bundles/redis-bundle: # config/services.yaml services: - Macpaw\RedisSchemaBundle\Redis\RedisClientAdapter: + Macpaw\RedisSchemaBundle\Redis\SchemaAwarePRedisClient: decorates: SymfonyBundles\RedisBundle\Redis\ClientInterface arguments: - '@.inner' diff --git a/src/Redis/RedisClientAdapter.php b/src/Redis/SchemaAwarePRedisClient.php similarity index 98% rename from src/Redis/RedisClientAdapter.php rename to src/Redis/SchemaAwarePRedisClient.php index 776a6fc..1dd3345 100644 --- a/src/Redis/RedisClientAdapter.php +++ b/src/Redis/SchemaAwarePRedisClient.php @@ -11,7 +11,7 @@ use Predis\Connection\ConnectionInterface; use SymfonyBundles\RedisBundle\Redis\ClientInterface; -class RedisClientAdapter implements ClientInterface +class SchemaAwarePRedisClient implements ClientInterface { public function __construct( private BaggageSchemaResolver $resolver, diff --git a/tests/Redis/RedisClientAdapterTest.php b/tests/Redis/SchemaAwarePRedisClientTest.php similarity index 87% rename from tests/Redis/RedisClientAdapterTest.php rename to tests/Redis/SchemaAwarePRedisClientTest.php index 4da6b1b..0e8ad14 100644 --- a/tests/Redis/RedisClientAdapterTest.php +++ b/tests/Redis/SchemaAwarePRedisClientTest.php @@ -4,7 +4,7 @@ namespace Macpaw\RedisSchemaBundle\Tests\Redis; -use Macpaw\RedisSchemaBundle\Redis\RedisClientAdapter; +use Macpaw\RedisSchemaBundle\Redis\SchemaAwarePRedisClient; use Macpaw\RedisSchemaBundle\Tests\Stub\TestRedisClient; use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver; use PHPUnit\Framework\MockObject\MockObject; @@ -16,7 +16,7 @@ use Predis\Configuration\OptionsInterface as PredisOptionsInterface; use Predis\Connection\ConnectionInterface as PredisConnectionInterface; -class RedisClientAdapterTest extends TestCase +class SchemaAwarePRedisClientTest extends TestCase { /** @var BaggageSchemaResolver&MockObject */ private BaggageSchemaResolver $resolver; @@ -33,7 +33,7 @@ public function testPopPrefixesKeyWithSchema(): void $client = new TestRedisClient(); $client->popReturn = 'value'; - $adapter = new RedisClientAdapter($this->resolver, $client); + $adapter = new SchemaAwarePRedisClient($this->resolver, $client); self::assertSame('value', $adapter->pop('mykey')); self::assertSame([ @@ -47,7 +47,7 @@ public function testPushPrefixesKeyAndForwardsValues(): void $client = new TestRedisClient(); $client->pushReturn = 3; - $adapter = new RedisClientAdapter($this->resolver, $client); + $adapter = new SchemaAwarePRedisClient($this->resolver, $client); self::assertSame(3, $adapter->push('list', 'a', 2, true)); self::assertSame([ @@ -62,7 +62,7 @@ public function testCountAndRemovePrefixKeys(): void $client->countReturn = 5; $client->removeReturn = 1; - $adapter = new RedisClientAdapter($this->resolver, $client); + $adapter = new SchemaAwarePRedisClient($this->resolver, $client); self::assertSame(5, $adapter->count('queue')); self::assertSame(1, $adapter->remove('to_remove')); @@ -77,7 +77,7 @@ public function testDefaultSchemaSkipsPrefixing(): void $this->resolver->method('getSchema')->willReturn('public'); $client = new TestRedisClient(); - $adapter = new RedisClientAdapter($this->resolver, $client); + $adapter = new SchemaAwarePRedisClient($this->resolver, $client); self::assertNull($adapter->pop('plain')); self::assertSame([ @@ -91,7 +91,7 @@ public function testDefaultSchemaReturnsKeyAsIs(): void $client = new TestRedisClient(); $client->pushReturn = 1; - $adapter = new RedisClientAdapter($this->resolver, $client); + $adapter = new SchemaAwarePRedisClient($this->resolver, $client); self::assertSame(1, $adapter->push('name', 'x')); self::assertSame([ @@ -113,7 +113,7 @@ public function testPassThroughClientMethods(): void $client->createCommandReturn = $command; $client->executeCommandReturn = 'PONG'; - $adapter = new RedisClientAdapter($this->resolver, $client); + $adapter = new SchemaAwarePRedisClient($this->resolver, $client); self::assertSame($factory, $adapter->getCommandFactory()); self::assertSame($options, $adapter->getOptions()); @@ -136,7 +136,7 @@ public function testMagicCallPrefixesFirstArgumentWhenScalar(): void return ['value1', 'value2']; }; - $adapter = new RedisClientAdapter($this->resolver, $client, ['hmget']); + $adapter = new SchemaAwarePRedisClient($this->resolver, $client, ['hmget']); $result = $adapter->hmget('hash', ['field1']); self::assertSame(['value1', 'value2'], $result); @@ -153,7 +153,7 @@ public function testMagicCallDoesNotPrefixWhenFirstArgumentNotScalar(): void return ['v']; }; - $adapter = new RedisClientAdapter($this->resolver, $client, ['hmget']); + $adapter = new SchemaAwarePRedisClient($this->resolver, $client, ['hmget']); $firstArg = 'hash'; $result = $adapter->hmget($firstArg, ['field']); @@ -170,7 +170,7 @@ public function testMagicCallWhenMethodNotInDecoratedListDoesNotPrefix(): void $client->dynamicHandlers['custom'] = fn(array $args) => 'ok'; // Not listing 'custom' in decoratedCallMethods, so it should not prefix the key - $adapter = new RedisClientAdapter($this->resolver, $client, ['xhmget']); + $adapter = new SchemaAwarePRedisClient($this->resolver, $client, ['xhmget']); // @phpstan-ignore-next-line $result = $adapter->custom('key', 'arg'); @@ -186,7 +186,7 @@ public function testMagicCallWhenMethodHasNoArguments(): void $client = new TestRedisClient(); $client->dynamicHandlers['ping'] = fn(array $args) => 'PONG'; - $adapter = new RedisClientAdapter($this->resolver, $client, ['ping']); + $adapter = new SchemaAwarePRedisClient($this->resolver, $client, ['ping']); $result = $adapter->ping(); self::assertSame('PONG', $result); From 6cf0ec60414537c74a0d5ea3daa130d02e05036a Mon Sep 17 00:00:00 2001 From: Serhii Donii Date: Tue, 11 Nov 2025 18:49:27 +0200 Subject: [PATCH 8/9] feat(PLATECO-2706): decorate predis client --- src/Redis/SchemaAwarePRedisClient.php | 115 ++++++++++++++++++-- tests/Redis/SchemaAwarePRedisClientTest.php | 67 ++++++++++++ 2 files changed, 172 insertions(+), 10 deletions(-) diff --git a/src/Redis/SchemaAwarePRedisClient.php b/src/Redis/SchemaAwarePRedisClient.php index 1dd3345..52b8d76 100644 --- a/src/Redis/SchemaAwarePRedisClient.php +++ b/src/Redis/SchemaAwarePRedisClient.php @@ -13,17 +13,19 @@ class SchemaAwarePRedisClient implements ClientInterface { + protected readonly array $decoratedCallMethods; + + /** + * @param array $decoratedCallMethods Method names that should be prefixed with + * the schema which not present in interfaces like a hmget, hset, etc. + */ public function __construct( private BaggageSchemaResolver $resolver, private ClientInterface $decorated, - /** - * Method names that should be prefixed with the schema which not present in interfaces - * like a hmget, hset, etc. - * - * @var array - */ - private readonly array $decoratedCallMethods = [], + array $decoratedCallMethods = [], ) { + $this->decoratedCallMethods = count($decoratedCallMethods) === 0 ? $this->getDefaultDecoratedMethods() + : $decoratedCallMethods; } public function pop(string $key): ?string @@ -90,9 +92,18 @@ public function executeCommand(CommandInterface $command): mixed public function __call($method, $arguments): mixed { if (in_array($method, $this->decoratedCallMethods, true)) { - // Automatically prefix the first argument if it's a string - if (array_key_exists(0, $arguments) && is_scalar($arguments[0])) { - $arguments[0] = $this->prefixKey((string) $arguments[0]); + // Automatically prefix the first argument if it's a string or an array of strings + if (array_key_exists(0, $arguments)) { + if (is_string($arguments[0])) { + $arguments[0] = $this->prefixKey($arguments[0]); + } + + if (is_array($arguments[0])) { + $arguments[0] = array_map( + fn(mixed $value): mixed => is_string($value) ? $this->prefixKey($value) : $value, + $arguments[0] + ); + } } } @@ -105,4 +116,88 @@ private function prefixKey(string $key): string return $schema === $this->resolver->getEnvironmentSchema() ? $key : $schema . '.' . $key; } + + /** + * @return array + */ + private function getDefaultDecoratedMethods(): array + { + return [ + // Basic key ops + 'copy', 'dump', 'exists', 'expire', 'expireat', 'expiretime', 'move', 'rename', 'renamenx', + 'sort', 'sort_ro', 'ttl', 'pttl', 'type', 'append', 'del', 'watch', 'unwatch', 'persist', 'touch', + 'pexpire', 'pexpireat', + + // Bloom filter (BF*) + 'bfadd', 'bfexists', 'bfinfo', 'bfinsert', 'bfloadchunk', 'bfmadd', 'bfmexists', + 'bfreserve', 'bfscandump', + + // Bit operations + 'bitcount', 'bitfield', 'bitfield_ro', 'bitpos', + + // Cuckoo filter (CF*) + 'cfadd', 'cfaddnx', 'cfcount', 'cfdel', 'cfexists', 'cfloadchunk', 'cfmexists', + 'cfinfo', 'cfinsert', 'cfinsertnx', 'cfreserve', 'cfscandump', + + // Count-Min Sketch (CMS*) + 'cmsincrby', 'cmsinfo', 'cmsinitbydim', 'cmsinitbyprob', 'cmsmerge', 'cmsquery', + + // String counters / String ops + 'decr', 'decrby', 'get', 'getbit', 'getex', 'getrange', 'getdel', 'getset', 'incr', 'incrby', 'incrbyfloat', + 'mget', 'psetex', 'set', 'setbit', 'setex', 'setnx', 'setrange', 'strlen', + + // Hashes (H*) + 'hdel', 'hexists', 'hexpire', 'hexpireat', 'hexpiretime', 'hpersist', 'hpexpire', + 'hpexpireat', 'hpexpiretime', 'hget', 'hgetex', 'hgetall', 'hgetdel', 'hincrby', + 'hincrbyfloat', 'hkeys', 'hlen', 'hmget', 'hmset', 'hrandfield', 'hscan', 'hset', + 'hsetex', 'hsetnx', 'httl', 'hpttl', 'hvals', 'hstrlen', + + // JSON (ReJSON) + 'jsonarrappend', 'jsonarrindex', 'jsonarrinsert', 'jsonarrlen', 'jsonarrpop', + 'jsonclear', 'jsonarrtrim', 'jsondel', 'jsonforget', 'jsonget', 'jsonnumincrby', + 'jsonmerge', 'jsonmget', 'jsonobjkeys', 'jsonobjlen', 'jsonresp', 'jsonset', 'jsonstrappend', + 'jsonstrlen', 'jsontoggle', 'jsontype', + + // Lists (L*) + 'blmove', 'blpop', 'brpop', 'brpoplpush', 'lcs', 'lindex', 'linsert', 'llen', 'lmove', 'lmpop', + 'lpop', 'lpush', 'lpushx', + 'lrange', 'lrem', 'lset', 'ltrim', 'rpop', 'rpoplpush', 'rpush', 'rpushx', + + // Sets (S*) + 'sadd', 'scard', 'sdiff', 'sdiffstore', 'sinter', 'sintercard', 'sinterstore', + 'sismember', 'smembers', 'smismember', + 'smove', 'spop', 'srandmember', 'srem', 'sscan', 'sunion', 'sunionstore', + + // T-Digest (TDIGEST*) + 'tdigestadd', 'tdigestbyrank', 'tdigestbyrevrank', 'tdigestcdf', 'tdigestcreate', + 'tdigestinfo', 'tdigestmax', 'tdigestmerge', 'tdigestquantile', 'tdigestmin', 'tdigestrank', + 'tdigestreset', 'tdigestrevrank', 'tdigesttrimmed_mean', + + // TOP-K (TOPK*) + 'topkadd', 'topkincrby', 'topkinfo', 'topklist', 'topkquery', 'topkreserve', + + // TimeSeries (TS*) + 'tsadd', 'tsalter', 'tscreate', 'tscreaterule', 'tsdecrby', 'tsdel', 'tsdeleterule', + 'tsget', 'tsincrby', 'tsinfo', 'tsrange', 'tsrevrange', + + // Streams (X*) + 'xadd', 'xdel', 'xlen', 'xrevrange', 'xrange', 'xtrim', + + // Sorted sets (Z*) + 'zadd', 'zcard', 'zcount', 'zdiff', 'zdiffstore', 'zincrby', 'zinter', 'zintercard', 'zinterstore', 'zmpop', + 'zmscore', 'zpopmin', 'zpopmax', 'bzpopmin', 'bzpopmax', 'bzmpop', 'zrandmember', 'zrange', + 'zrangebyscore', 'zrangestore', 'zrank', 'zrem', 'zremrangebyrank', 'zremrangebyscore', + 'zrevrange', 'zrevrangebyscore', 'zrevrank', 'zscore', 'zscan', 'zrangebylex', + 'zrevrangebylex', 'zremrangebylex', 'zlexcount', + + // HyperLogLog + 'pfadd', 'pfmerge', 'pfcount', + + // Key TTL precise + 'pexpiretime', + + // Geo + 'geoadd', 'geohash', 'geopos', 'geodist', 'georadius', 'georadiusbymember', 'geosearch', 'geosearchstore', + ]; + } } diff --git a/tests/Redis/SchemaAwarePRedisClientTest.php b/tests/Redis/SchemaAwarePRedisClientTest.php index 0e8ad14..72ab7e1 100644 --- a/tests/Redis/SchemaAwarePRedisClientTest.php +++ b/tests/Redis/SchemaAwarePRedisClientTest.php @@ -194,4 +194,71 @@ public function testMagicCallWhenMethodHasNoArguments(): void ['method' => 'ping', 'args' => []], ], $client->calls); } + + public function testMagicCallDefaultMethodsScalarFirstArg(): void + { + $this->resolver->method('getSchema')->willReturn('s'); + $client = new TestRedisClient(); + $client->dynamicHandlers['get'] = static function (array $args) { + return 'val'; + }; + + // No third parameter passed -> use default decorated methods list + $adapter = new SchemaAwarePRedisClient($this->resolver, $client); + + $result = $adapter->get('key1'); + self::assertSame('val', $result); + self::assertSame([ + ['method' => 'get', 'args' => ['s.key1']], + ], $client->calls); + } + + public function testMagicCallDefaultMethodsArrayOfScalarsAsFirstArg(): void + { + $this->resolver->method('getSchema')->willReturn('s'); + $client = new TestRedisClient(); + $client->dynamicHandlers['del'] = static function (array $args) { + return 2; // pretend two keys exist + }; + + $adapter = new SchemaAwarePRedisClient($this->resolver, $client); + + // Pass an array of scalar keys as the first argument + $result = $adapter->del(['k1', 'k2']); + self::assertSame(2, $result); + self::assertSame([ + ['method' => 'del', 'args' => [['s.k1', 's.k2']]], + ], $client->calls); + } + + public function testMagicCallDefaultMethodsArrayWithMixedScalarsAndNonScalars(): void + { + $this->resolver->method('getSchema')->willReturn('s'); + $client = new TestRedisClient(); + $client->dynamicHandlers['watch'] = static function (array $args) { + return 1; + }; + + $adapter = new SchemaAwarePRedisClient($this->resolver, $client); + + $nonScalar = new \stdClass(); + $firstArg = ['k1', ['nested'], $nonScalar, 5, 7.5, true, null]; + $result = $adapter->watch($firstArg); + self::assertSame(1, $result); + + self::assertSame([ + [ + 'method' => 'watch', + 'args' => [[ + 's.k1', + ['nested'], + $nonScalar, + 5, + 7.5, + true, + null, + ]], + ], + ], $client->calls); + } } From e989d4d37d564fbf237e7be5c4b847bac84946f3 Mon Sep 17 00:00:00 2001 From: Serhii Donii Date: Tue, 11 Nov 2025 18:52:16 +0200 Subject: [PATCH 9/9] feat(PLATECO-2706): decorate predis client --- src/Redis/SchemaAwarePRedisClient.php | 3 +++ tests/Redis/SchemaAwarePRedisClientTest.php | 1 + 2 files changed, 4 insertions(+) diff --git a/src/Redis/SchemaAwarePRedisClient.php b/src/Redis/SchemaAwarePRedisClient.php index 52b8d76..c642690 100644 --- a/src/Redis/SchemaAwarePRedisClient.php +++ b/src/Redis/SchemaAwarePRedisClient.php @@ -13,6 +13,9 @@ class SchemaAwarePRedisClient implements ClientInterface { + /** + * @var array + */ protected readonly array $decoratedCallMethods; /** diff --git a/tests/Redis/SchemaAwarePRedisClientTest.php b/tests/Redis/SchemaAwarePRedisClientTest.php index 72ab7e1..920af09 100644 --- a/tests/Redis/SchemaAwarePRedisClientTest.php +++ b/tests/Redis/SchemaAwarePRedisClientTest.php @@ -243,6 +243,7 @@ public function testMagicCallDefaultMethodsArrayWithMixedScalarsAndNonScalars(): $nonScalar = new \stdClass(); $firstArg = ['k1', ['nested'], $nonScalar, 5, 7.5, true, null]; + // @phpstan-ignore-next-line $result = $adapter->watch($firstArg); self::assertSame(1, $result);