diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index 8796ae1..5edf43a 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -6,8 +6,8 @@ on: branches: [ main, develop ] jobs: - php-cs-fixer: - name: PHP-CS-Fixer + php-code-sniffer: + name: PHP Code Sniffer runs-on: ubuntu-latest steps: - name: Checkout 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..c8655f1 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\SchemaAwarePRedisClient: + 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..7f316dd 100644 --- a/composer.json +++ b/composer.json @@ -15,19 +15,23 @@ }, "require": { "php": ">=8.3", - "macpaw/schema-context-bundle": "^1.1", + "macpaw/schema-context-bundle": "^2.0", "symfony/cache": "^6.4|^7.0" }, "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/SchemaAwarePRedisClient.php b/src/Redis/SchemaAwarePRedisClient.php new file mode 100644 index 0000000..c642690 --- /dev/null +++ b/src/Redis/SchemaAwarePRedisClient.php @@ -0,0 +1,206 @@ + + */ + 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, + array $decoratedCallMethods = [], + ) { + $this->decoratedCallMethods = count($decoratedCallMethods) === 0 ? $this->getDefaultDecoratedMethods() + : $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(); + } + + /** + * @param array $arguments + */ + public function createCommand($method, $arguments = []): CommandInterface + { + return $this->decorated->createCommand($method, $arguments); + } + + 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)) { + // 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] + ); + } + } + } + + return $this->decorated->$method(...$arguments); + } + + private function prefixKey(string $key): string + { + $schema = $this->resolver->getSchema(); + + 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/src/Redis/SchemaAwareRedisAdapter.php b/src/Redis/SchemaAwareRedisAdapter.php index e8242e5..25fb2d4 100644 --- a/src/Redis/SchemaAwareRedisAdapter.php +++ b/src/Redis/SchemaAwareRedisAdapter.php @@ -20,13 +20,6 @@ public function __construct( ) { } - 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); @@ -98,6 +91,15 @@ 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); + } + + private function prefixKey(string $key): string + { + $schema = $this->resolver->getSchema(); + + return $schema === $this->resolver->getEnvironmentSchema() ? $key : $schema . '.' . $key; } } diff --git a/tests/Redis/SchemaAwarePRedisClientTest.php b/tests/Redis/SchemaAwarePRedisClientTest.php new file mode 100644 index 0000000..920af09 --- /dev/null +++ b/tests/Redis/SchemaAwarePRedisClientTest.php @@ -0,0 +1,265 @@ +resolver = $this->createMock(BaggageSchemaResolver::class); + $this->resolver->method('getEnvironmentSchema')->willReturn('public'); + } + + public function testPopPrefixesKeyWithSchema(): void + { + $this->resolver->method('getSchema')->willReturn('test'); + $client = new TestRedisClient(); + $client->popReturn = 'value'; + + $adapter = new SchemaAwarePRedisClient($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 SchemaAwarePRedisClient($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 SchemaAwarePRedisClient($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('public'); + $client = new TestRedisClient(); + + $adapter = new SchemaAwarePRedisClient($this->resolver, $client); + + self::assertNull($adapter->pop('plain')); + self::assertSame([ + ['method' => 'pop', 'args' => ['plain']], + ], $client->calls); + } + + public function testDefaultSchemaReturnsKeyAsIs(): void + { + $this->resolver->method('getSchema')->willReturn('public'); + $client = new TestRedisClient(); + $client->pushReturn = 1; + + $adapter = new SchemaAwarePRedisClient($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 SchemaAwarePRedisClient($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['hmget'] = function (array $args) { + return ['value1', 'value2']; + }; + + $adapter = new SchemaAwarePRedisClient($this->resolver, $client, ['hmget']); + + $result = $adapter->hmget('hash', ['field1']); + self::assertSame(['value1', 'value2'], $result); + self::assertSame([ + ['method' => 'hmget', 'args' => ['s.hash', ['field1']]], + ], $client->calls); + } + + public function testMagicCallDoesNotPrefixWhenFirstArgumentNotScalar(): void + { + $this->resolver->method('getSchema')->willReturn('s'); + $client = new TestRedisClient(); + $client->dynamicHandlers['hmget'] = function (array $args) { + return ['v']; + }; + + $adapter = new SchemaAwarePRedisClient($this->resolver, $client, ['hmget']); + + $firstArg = 'hash'; + $result = $adapter->hmget($firstArg, ['field']); + self::assertSame(['v'], $result); + self::assertSame([ + ['method' => 'hmget', 'args' => ['s.' . $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 SchemaAwarePRedisClient($this->resolver, $client, ['xhmget']); + + // @phpstan-ignore-next-line + $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['ping'] = fn(array $args) => 'PONG'; + + $adapter = new SchemaAwarePRedisClient($this->resolver, $client, ['ping']); + + $result = $adapter->ping(); + self::assertSame('PONG', $result); + self::assertSame([ + ['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]; + // @phpstan-ignore-next-line + $result = $adapter->watch($firstArg); + self::assertSame(1, $result); + + self::assertSame([ + [ + 'method' => 'watch', + 'args' => [[ + 's.k1', + ['nested'], + $nonScalar, + 5, + 7.5, + true, + null, + ]], + ], + ], $client->calls); + } +} diff --git a/tests/Stub/TestRedisClient.php b/tests/Stub/TestRedisClient.php new file mode 100644 index 0000000..c47049e --- /dev/null +++ b/tests/Stub/TestRedisClient.php @@ -0,0 +1,115 @@ +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'); + } + + /** + * @param array $arguments + */ + 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; + } + + /** + * @param array $arguments + */ + public function __call($name, $arguments) + { + $this->calls[] = ['method' => $name, 'args' => $arguments]; + if (array_key_exists($name, $this->dynamicHandlers)) { + return ($this->dynamicHandlers[$name])($arguments); + } + throw new \BadMethodCallException("No handler for dynamic method {$name}"); + } +}