diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index a65e53f..c99ca5e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -53,8 +53,10 @@ jobs: run: vendor/bin/php-cs-fixer fix src --dry-run --diff --no-ansi - name: Run phpstan run: vendor/bin/phpstan analyse src --level=5 - - name: Run tests - run: XDEBUG_MODE=coverage vendor/bin/phpunit + - name: Run tests with redis extension + run: XDEBUG_MODE=coverage REDIS_CLIENT=redis vendor/bin/phpunit + - name: Run tests with predis dependency + run: XDEBUG_MODE=coverage REDIS_CLIENT=predis vendor/bin/phpunit - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: diff --git a/composer.json b/composer.json index 609c256..506e813 100644 --- a/composer.json +++ b/composer.json @@ -14,9 +14,12 @@ }, "require": { "php": ">=8.2", - "ext-redis": "*", "ext-json": "*" }, + "suggest": { + "ext-redis": "To use the native Redis extension (phpredis)", + "predis/predis": "To use Predis as a Redis PHP client" + }, "require-dev": { "phpunit/phpunit": "^11.0", "friendsofphp/php-cs-fixer": "^3.57", diff --git a/src/Client/PRedisClient.php b/src/Client/PRedisClient.php new file mode 100644 index 0000000..322fb06 --- /dev/null +++ b/src/Client/PRedisClient.php @@ -0,0 +1,414 @@ +redis = $redis ?? new PRedis([ + 'host' => array_key_exists('REDIS_HOST', $_SERVER) ? ['host' => $_SERVER['REDIS_HOST']] : null, + ] + ); + } + + public function createPersistentConnection(?string $host = null, ?int $port = null, ?int $timeout = 0): void + { + $parameters = $this->redis->getConnection()->getParameters()->toArray(); + + $this->redis = new PRedis([ + 'scheme' => 'tcp', + 'host' => $host ?? $parameters['host'], + 'port' => $port ?? $parameters['port'], + 'persistent' => true, + 'timeout' => $timeout, + ]);; + } + + /** + * @inheritDoc + */ + public function hMSet(string $key, array $data): void + { + if (!$this->redis->hmset(RedisClient::convertPrefix($key), $data)) { + $this->handleError(__METHOD__, $this->redis->getLastError()); + } + } + + + /** + * @inheritdoc + */ + public function hGetAll(string $key): array + { + $result = $this->redis->hgetall(RedisClient::convertPrefix($key)); + + if ($result === false) { + $this->handleError(__METHOD__, $this->redis->getLastError()); + } + + return $result; + } + + /** + * @inheritdoc + */ + public function hget(string $key, string $property): string + { + $result = $this->redis->hget(RedisClient::convertPrefix($key), $property); + + if (!$result) { + $this->handleError(__METHOD__, $this->redis->getLastError()); + } + + return $result; + } + + /** + * @inheritdoc + */ + public function del(string $key): void + { + if (!$this->redis->del(RedisClient::convertPrefix($key))) { + $this->handleError(__METHOD__, $this->redis->getLastError()); + } + } + + /** + * @inheritdoc + */ + public function jsonGet(string $key): ?string + { + $result = $this->redis->rawCommand(RedisCommands::JSON_GET->value, RedisClient::convertPrefix($key)); + + if ($result === false) { + return null; + } + + return $result; + } + + /** + * @inheritdoc + */ + public function jsonGetProperty(string $key, string $property): ?string + { + $result = $this->redis->rawCommand(RedisCommands::JSON_GET->value, RedisClient::convertPrefix($key), "$.$property"); + + if ($result === false) { + return null; + } + + return $result; + } + + /** + * @inheritdoc + */ + public function jsonSet(string $key, ?string $path = '$', ?string $value = '{}'): void + { + if (!$this->redis->rawCommand(RedisCommands::JSON_SET->value, RedisClient::convertPrefix($key), $path, $value)) { + $this->handleError(__METHOD__, $this->redis->getLastError()); + } + } + + /** + * @inheritdoc + */ + public function jsonMSet(...$params): void + { + $arguments = [RedisCommands::JSON_MSET->value]; + foreach ($params as $param) { + if (count($param) % 3 !== 0) { + throw new \InvalidArgumentException("Should provide 3 parameters for each key, path and value"); + } + + for ($i = 0; $i < count($param); $i += 3) { + $arguments[] = RedisClient::convertPrefix($param[$i]); + $arguments[] = $param[$i + 1] ?? '$'; + $arguments[] = $param[$i + 2] ?? '{}'; + } + } + + if (!call_user_func_array([$this->redis, 'rawCommand'], $arguments)) { + $this->handleError(__METHOD__, $this->redis->getLastError()); + } + } + + /** + * @inheritdoc + */ + public function jsonDel(string $key, ?string $path = '$'): void + { + if (!$this->redis->rawCommand(RedisCommands::JSON_DELETE->value, RedisClient::convertPrefix($key), $path)) { + $this->handleError(__METHOD__, $this->redis->getLastError()); + } + } + + /** + * @inheritdoc + */ + public function createIndex(string $prefixKey, ?string $format = RedisFormat::HASH->value, ?array $properties = []): void + { + if ($properties === []) { + return; + } + + $prefixKey = self::convertPrefix($prefixKey); + + $arguments = [ + RedisCommands::CREATE_INDEX->value, + $prefixKey, + 'ON', + $format, + ]; + + if ($format === RedisFormat::HASH->value) { + $arguments[] = 'PREFIX'; + $arguments[] = '1'; + $arguments[] = "$prefixKey:"; + } + + $arguments[] = 'SCHEMA'; + + /** @var PropertyToIndex $propertyToIndex */ + foreach ($properties as $propertyToIndex) { + $arguments[] = $propertyToIndex->name; + $arguments[] = 'AS'; + $arguments[] = $propertyToIndex->indexName; + $arguments[] = $propertyToIndex->indexType; + $arguments[] = 'SORTABLE'; + } + + if (end($arguments) === 'SCHEMA') { + throw new BadPropertyConfigurationException(sprintf('Your class %s does not have any typed property', $prefixKey)); + } + + if (!call_user_func_array([$this->redis, 'rawCommand'], $arguments)) { + $this->handleError(__METHOD__, $this->redis->getLastError()); + } + } + + /** + * @inheritdoc + */ + public function dropIndex(string $prefixKey): bool + { + try { + $key = self::convertPrefix($prefixKey); + $this->redis->rawCommand(RedisCommands::DROP_INDEX->value, $key); + } catch (\RedisException) { + return false; + } + + return true; + } + + /** + * @inheritdoc + */ + public function count(string $prefixKey, array $criterias = []): int + { + $arguments = [RedisCommands::SEARCH->value, static::convertPrefix($prefixKey)]; + + foreach ($criterias as $property => $value) { + $arguments[] = "@$property:$value"; + } + + $rawResult = call_user_func_array([$this->redis, 'rawCommand'], $arguments); + + if (!$rawResult) { + $this->handleError(__METHOD__, $this->redis->getLastError()); + } + + return (int)$rawResult[0]; + } + + /** + * @inheritdoc + */ + public function scanKeys(string $prefixKey): array + { + $keys = []; + $iterator = null; + while ($iterator !== 0) { + $scans = $this->redis->scan($iterator, sprintf('%s*', static::convertPrefix($prefixKey))); + foreach ($scans as $scan) { + $keys[] = $scan; + } + } + + return $keys; + } + + /** + * @inheritdoc + */ + public function flushAll(): void + { + if (!$this->redis->flushAll()) { + $this->handleError(__METHOD__, $this->redis->getLastError()); + } + } + + /** + * @inheritdoc + */ + public function keys(string $pattern): array + { + return $this->redis->keys($pattern); + } + + /** + * @inheritdoc + */ + public function search(string $prefixKey, array $search, array $orderBy, ?string $format = RedisFormat::HASH->value, ?int $numberOfResults = null, ?string $searchType = Property::INDEX_TAG): array + { + $arguments = [RedisCommands::SEARCH->value, self::convertPrefix($prefixKey)]; + + if ($search === []) { + $arguments[] = '*'; + } else { + $criteria = ''; + foreach ($search as $property => $value) { + if ($searchType === Property::INDEX_TAG) { + $criteria .= sprintf('@%s:{%s}', $property, $value); + } else { + $criteria .= sprintf('@%s:%s', $property, $value); + } + } + + $arguments[] = $criteria; + } + + foreach ($orderBy as $property => $direction) { + $arguments[] = 'SORTBY'; + $arguments[] = $property; + $arguments[] = $direction; + } + + try { + $result = call_user_func_array([$this->redis, 'rawCommand'], $arguments); + } catch (\RedisException $e) { + $this->handleError(RedisCommands::SEARCH->value, $e->getMessage(), $e); + } + + if ($result === false) { + $this->handleError(RedisCommands::SEARCH->value, $this->redis->getLastError()); + } + + if ($result[0] === 0) { + return []; + } + + return $this->extractRedisData($result, $format, $numberOfResults); + } + + /** + * @inheritdoc + */ + public function customSearch(string $prefixKey, string $query, string $format): array + { + $arguments = [RedisCommands::SEARCH->value, self::convertPrefix($prefixKey), $query]; + + try { + $result = call_user_func_array([$this->redis, 'rawCommand'], $arguments); + } catch (\RedisException $e) { + $this->handleError(RedisCommands::SEARCH->value, $e->getMessage(), $e); + } + + if ($result === false) { + $this->handleError(RedisCommands::SEARCH->value, $this->redis->getLastError()); + } + + if ($result[0] === 0) { + return []; + } + + return $this->extractRedisData($result, $format); + } + + /** + * @inheritdoc + */ + public function searchLike(string $prefixKey, string $search, ?string $format = RedisFormat::HASH->value, ?int $numberOfResults = null): array + { + $arguments = [RedisCommands::SEARCH->value, static::convertPrefix($prefixKey)]; + + $arguments[] = $search; + + try { + $result = call_user_func_array([$this->redis, 'rawCommand'], $arguments); + } catch (\RedisException $e) { + $this->handleError(RedisCommands::SEARCH->value, $e->getMessage(), $e); + } + + if ($result === false) { + $this->handleError(RedisCommands::SEARCH->value, $this->redis->getLastError()); + } + + if ($result[0] === 0) { + return []; + } + + return $this->extractRedisData($result, $format, $numberOfResults); + } + + public static function convertPrefix(string $key): string + { + return str_replace('\\', '_', $key); + } + + private function handleError(string $command, ?string $errorMessage = 'Unknown error', ?\Throwable $previous = null): never + { + throw new RedisClientResponseException( + sprintf('something was wrong when executing %s command, reason: %s', $command, $errorMessage), + $previous?->getCode() ?? 0, + $previous + ); + } + + private function extractRedisData(array $result, string $format, ?int $numberOfResults = null): array + { + $entities = []; + foreach ($result as $key => $redisData) { + if ($key > 0 && $key % 2 == 0) { + + if ($format === RedisFormat::JSON->value) { + foreach ($redisData as $data) { + if (!str_starts_with($data, '{')) { + continue; + } + $entities[] = json_decode($data, true); + break; + } + + continue; + } else { + $data = []; + for ($i = 0; $i < count($redisData); $i += 2) { + $property = $redisData[$i]; + $value = $redisData[$i + 1]; + $data[$property] = $value; + } + } + + $entities[] = $data; + + if (count($entities) === $numberOfResults) { + return $entities; + } + } + } + + return $entities; + } +} \ No newline at end of file diff --git a/src/Om/Persister/AbstractPersister.php b/src/Om/Persister/AbstractPersister.php index 451a778..ba7b6d2 100644 --- a/src/Om/Persister/AbstractPersister.php +++ b/src/Om/Persister/AbstractPersister.php @@ -11,11 +11,12 @@ abstract class AbstractPersister implements PersisterInterface { - protected RedisClientInterface $redis; - public function __construct(private ?KeyGenerator $keyGenerator = null) + + public function __construct(private ?KeyGenerator $keyGenerator = null, + protected ?RedisClientInterface $redis = null) { - $this->redis = (new RedisClient()); + $this->redis = $redis ?? (new RedisClient()); $this->keyGenerator = $keyGenerator ?? new KeyGenerator(); } diff --git a/tests/Client/Client.php b/tests/Client/Client.php index 4c3c552..1c3ae11 100644 --- a/tests/Client/Client.php +++ b/tests/Client/Client.php @@ -4,6 +4,7 @@ namespace Talleu\RedisOm\Tests\Client; +use Talleu\RedisOm\Client\PRedisClient; use Talleu\RedisOm\Client\RedisClient; use Talleu\RedisOm\Client\RedisClientInterface; @@ -13,7 +14,7 @@ class Client public function __construct() { - $this->redisClient = new RedisClient(); + $this->redisClient = getenv('REDIS_CLIENT') === 'predis' ? new PredisClient() : new RedisClient(); $this->redisClient->createPersistentConnection($_SERVER['REDIS_HOST']); } }