From a27275821f2ba7305420b56561d330aae30475fc Mon Sep 17 00:00:00 2001 From: clementtalleu Date: Tue, 31 Mar 2026 20:32:10 +0200 Subject: [PATCH 1/3] tests ok --- README.md | 116 +++++++++++- src/Client/PredisClient.php | 88 +++++++++- src/Client/RedisClient.php | 88 +++++++++- src/Client/RedisClientInterface.php | 44 ++++- src/Command/GenerateSchema.php | 20 +++ .../Converters/AbstractConverterFactory.php | 40 +++++ src/Om/Converters/EnumConverter.php | 45 +++++ .../Converters/HashModel/ConverterFactory.php | 2 + .../Converters/JsonModel/ConverterFactory.php | 2 + src/Om/Paginator.php | 78 +++++++++ src/Om/Persister/HashModel/HashPersister.php | 13 ++ src/Om/Persister/JsonModel/JsonPersister.php | 16 ++ src/Om/Persister/ObjectToPersist.php | 1 + src/Om/Persister/PersisterInterface.php | 6 + src/Om/Persister/PersisterOperations.php | 1 + src/Om/RedisObjectManager.php | 128 ++++++++++++-- src/Om/RedisObjectManagerInterface.php | 12 ++ .../Repository/AbstractObjectRepository.php | 119 ++++++++++++- .../Repository/HashModel/HashRepository.php | 16 ++ .../Repository/JsonModel/JsonRepository.php | 18 ++ src/Om/Repository/RepositoryInterface.php | 72 ++++++-- tests/Fixtures/Hash/EnumDummyHash.php | 26 +++ tests/Fixtures/Json/EnumDummyJson.php | 27 +++ tests/Fixtures/PriorityEnum.php | 12 ++ tests/Fixtures/StatusEnum.php | 12 ++ tests/Functionnal/Om/Merge/MergeTest.php | 101 +++++++++++ .../Om/Paginator/PaginatorTest.php | 102 +++++++++++ .../HashModel/EnumRepositoryTest.php | 105 +++++++++++ .../Repository/HashModel/FindMultipleTest.php | 59 +++++++ .../Repository/HashModel/RangeQueryTest.php | 98 +++++++++++ .../JsonModel/EnumRepositoryTest.php | 92 ++++++++++ .../Repository/JsonModel/FindMultipleTest.php | 47 +++++ .../Repository/JsonModel/RangeQueryTest.php | 20 +++ .../Unit/Om/Converters/EnumConverterTest.php | 144 +++++++++++++++ .../HashModel/ConverterFactoryTest.php | 5 + tests/Unit/Om/MergeTest.php | 151 ++++++++++++++++ tests/Unit/Om/PaginatorTest.php | 156 +++++++++++++++++ tests/Unit/Om/RedisObjectManagerTest.php | 54 ++++++ tests/Unit/Om/Repository/FindMultipleTest.php | 91 ++++++++++ tests/Unit/Om/Repository/GeoQueryTest.php | 79 +++++++++ tests/Unit/Om/Repository/RangeQueryTest.php | 165 ++++++++++++++++++ 41 files changed, 2438 insertions(+), 33 deletions(-) create mode 100644 src/Om/Converters/EnumConverter.php create mode 100644 src/Om/Paginator.php create mode 100644 tests/Fixtures/Hash/EnumDummyHash.php create mode 100644 tests/Fixtures/Json/EnumDummyJson.php create mode 100644 tests/Fixtures/PriorityEnum.php create mode 100644 tests/Fixtures/StatusEnum.php create mode 100644 tests/Functionnal/Om/Merge/MergeTest.php create mode 100644 tests/Functionnal/Om/Paginator/PaginatorTest.php create mode 100644 tests/Functionnal/Om/Repository/HashModel/EnumRepositoryTest.php create mode 100644 tests/Functionnal/Om/Repository/HashModel/FindMultipleTest.php create mode 100644 tests/Functionnal/Om/Repository/HashModel/RangeQueryTest.php create mode 100644 tests/Functionnal/Om/Repository/JsonModel/EnumRepositoryTest.php create mode 100644 tests/Functionnal/Om/Repository/JsonModel/FindMultipleTest.php create mode 100644 tests/Functionnal/Om/Repository/JsonModel/RangeQueryTest.php create mode 100644 tests/Unit/Om/Converters/EnumConverterTest.php create mode 100644 tests/Unit/Om/MergeTest.php create mode 100644 tests/Unit/Om/PaginatorTest.php create mode 100644 tests/Unit/Om/Repository/FindMultipleTest.php create mode 100644 tests/Unit/Om/Repository/GeoQueryTest.php create mode 100644 tests/Unit/Om/Repository/RangeQueryTest.php diff --git a/README.md b/README.md index 9dedad3..2cd3aab 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,14 @@ with Redis. - High performance and scalability with Redisยฎ - Support for Redis JSON module - Automatic schema generation -- Search and query capabilities +- Search and query capabilities with range filters - Auto-expiration of your objects +- PHP enum support (backed enums) +- Identity map and dirty tracking with partial updates +- Atomic transactions (MULTI/EXEC) +- Pagination with total count +- GEO queries (radius search) +- Pipeline batch reads - API Platform support (beta) ## Requirements โš™๏ธ @@ -36,6 +42,7 @@ with Redis. ## Supported types โœ… - scalar (string, int, float, bool, double) +- PHP backed enums (string and int) - timestamp - json - null @@ -172,6 +179,113 @@ $users = $this->redisObjectManager->getRepository(User::class)->findBy(['name' = ``` +## Enum Support ๐Ÿท๏ธ + +PHP backed enums are natively supported: + +```php +enum Status: string +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; +} + +#[RedisOm\Entity] +class Task +{ + #[RedisOm\Id] + #[RedisOm\Property] + public int $id; + + #[RedisOm\Property(index: true)] + public Status $status; +} +``` + +Search by enum value: +```php +$activeTasks = $repository->findBy(['status' => 'active']); +``` + +## Range Queries ๐Ÿ”ข + +Use MongoDB-style operators for numeric range searches: + +```php +// Age between 18 and 65 +$users = $repository->findBy(['age' => ['$gte' => 18, '$lte' => 65]]); + +// Price greater than 100 +$products = $repository->findBy(['price' => ['$gt' => 100]]); + +// Score less than 50 +$results = $repository->findBy(['score' => ['$lt' => 50]]); + +// Combine with exact match +$results = $repository->findBy(['name' => 'John', 'age' => ['$gte' => 18]]); +``` + +Supported operators: `$gte` (>=), `$gt` (>), `$lte` (<=), `$lt` (<). + +> **Note:** Range queries work automatically with HASH format (NUMERIC index is auto-generated for int/float). +> For JSON format, you must explicitly declare a NUMERIC index: `#[Property(index: ['age' => 'NUMERIC'])]`. + +## Pagination ๐Ÿ“„ + +```php +$paginator = $repository->paginate( + criteria: ['status' => 'active'], + page: 2, + itemsPerPage: 20, + orderBy: ['createdAt' => 'DESC'] +); + +$paginator->getItems(); // Current page items +$paginator->getTotalItems(); // Total matching count +$paginator->getTotalPages(); // Total number of pages +$paginator->getCurrentPage(); // Current page number +$paginator->hasNextPage(); // bool +$paginator->hasPreviousPage(); // bool + +// Iterable +foreach ($paginator as $item) { + // ... +} +``` + +## Partial Updates (Merge) โšก + +Instead of re-persisting the entire object, use `merge()` to only update changed fields: + +```php +$user = $objectManager->find(User::class, 1); +$user->name = 'New Name'; // Only this field changed + +$objectManager->merge($user); // Detects change, updates only 'name' +$objectManager->flush(); +``` + +For new objects (not loaded via `find()`), `merge()` falls back to a full `persist()`. + +## Batch Reads (Pipeline) ๐Ÿš€ + +Load multiple objects by ID in a single Redis pipeline call: + +```php +$users = $repository->findMultiple([1, 2, 3, 4, 5]); +``` + +## GEO Queries ๐ŸŒ + +Search objects within a geographic radius (requires a GEO-indexed property): + +```php +#[RedisOm\Property(index: ['location' => 'GEO'])] +public string $location; // Format: "longitude,latitude" + +$nearby = $repository->findByGeoRadius('location', 2.3522, 48.8566, 10, 'km'); +``` + ## Advanced documentation ๐Ÿ“š - [Installation](https://github.com/clementtalleu/php-redis-om/blob/main/docs/installation.md) - [Configuration](https://github.com/clementtalleu/php-redis-om/blob/main/docs/configuration.md) diff --git a/src/Client/PredisClient.php b/src/Client/PredisClient.php index d6787f6..50d2185 100644 --- a/src/Client/PredisClient.php +++ b/src/Client/PredisClient.php @@ -73,6 +73,14 @@ private function getLastError() } + /** + * @inheritdoc + */ + public function hSet(string $key, string $field, string $value): void + { + $this->redis->hset(Converter::prefix($key), $field, $value); + } + /** * @inheritdoc */ @@ -143,6 +151,14 @@ public function jsonSet(string $key, ?string $path = '$', ?string $value = '{}') } } + /** + * @inheritdoc + */ + public function jsonSetProperty(string $key, string $property, string $value): void + { + $this->redis->executeRaw([RedisCommands::JSON_SET->value, Converter::prefix($key), '$.' . $property, $value]); + } + /** * @inheritdoc */ @@ -321,6 +337,70 @@ public function expireTime(string $key): int return $timestamp; } + /** + * @inheritdoc + */ + public function hGetAllMultiple(array $keys): array + { + $pipeline = $this->redis->pipeline(); + foreach ($keys as $key) { + $pipeline->hgetall(Converter::prefix($key)); + } + $results = $pipeline->execute(); + + $data = []; + foreach ($keys as $i => $key) { + if (!empty($results[$i])) { + $data[$key] = $results[$i]; + } + } + + return $data; + } + + /** + * @inheritdoc + */ + public function jsonGetMultiple(array $keys): array + { + $results = $this->redis->pipeline(function ($pipeline) use ($keys) { + foreach ($keys as $key) { + $pipeline->executeRaw([RedisCommands::JSON_GET->value, Converter::prefix($key)]); + } + }); + + $data = []; + foreach ($keys as $i => $key) { + $data[$key] = $results[$i] !== false ? $results[$i] : null; + } + + return $data; + } + + /** + * @inheritdoc + */ + public function multi(): void + { + $this->redis->multi(); + } + + /** + * @inheritdoc + */ + public function exec(): void + { + $this->redis->exec(); + } + + /** + * @inheritdoc + */ + public function discard(): void + { + $this->redis->discard(); + } + /** * @inheritdoc */ @@ -332,11 +412,11 @@ public function keys(string $pattern): array /** * @inheritdoc */ - public function search(string $prefixKey, array $search, array $orderBy, ?string $format = RedisFormat::HASH->value, ?int $numberOfResults = null, int $offset = 0, ?string $searchType = Property::INDEX_TAG): array + public function search(string $prefixKey, array $search, array $orderBy, ?string $format = RedisFormat::HASH->value, ?int $numberOfResults = null, int $offset = 0, ?string $searchType = Property::INDEX_TAG, array $rangeFilters = []): array { $arguments = [RedisCommands::SEARCH->value, Converter::prefix($prefixKey)]; - if ($search === []) { + if ($search === [] && $rangeFilters === []) { $arguments[] = '*'; } else { $criteria = ''; @@ -351,6 +431,10 @@ public function search(string $prefixKey, array $search, array $orderBy, ?string } } + foreach ($rangeFilters as $rangeQuery) { + $criteria .= $rangeQuery; + } + $arguments[] = $criteria; } diff --git a/src/Client/RedisClient.php b/src/Client/RedisClient.php index 576f86c..c7def71 100644 --- a/src/Client/RedisClient.php +++ b/src/Client/RedisClient.php @@ -68,6 +68,14 @@ public function hget(string $key, string $property): string return $result; } + /** + * @inheritdoc + */ + public function hSet(string $key, string $field, string $value): void + { + $this->redis->hSet(Converter::prefix($key), $field, $value); + } + /** * @inheritdoc */ @@ -130,6 +138,14 @@ public function jsonSet(string $key, ?string $path = '$', ?string $value = '{}') } } + /** + * @inheritdoc + */ + public function jsonSetProperty(string $key, string $property, string $value): void + { + $this->redis->rawCommand(RedisCommands::JSON_SET->value, Converter::prefix($key), '$.' . $property, $value); + } + /** * @inheritdoc */ @@ -312,6 +328,70 @@ public function expireTime(string $key): int return (int) $timestamp; } + /** + * @inheritdoc + */ + public function hGetAllMultiple(array $keys): array + { + $pipeline = $this->redis->pipeline(); + foreach ($keys as $key) { + $pipeline->hGetAll(Converter::prefix($key)); + } + $results = $pipeline->exec(); + + $data = []; + foreach ($keys as $i => $key) { + if (!empty($results[$i])) { + $data[$key] = $results[$i]; + } + } + + return $data; + } + + /** + * @inheritdoc + */ + public function jsonGetMultiple(array $keys): array + { + $pipeline = $this->redis->pipeline(); + foreach ($keys as $key) { + $pipeline->rawCommand(RedisCommands::JSON_GET->value, Converter::prefix($key)); + } + $results = $pipeline->exec(); + + $data = []; + foreach ($keys as $i => $key) { + $data[$key] = $results[$i] !== false ? $results[$i] : null; + } + + return $data; + } + + /** + * @inheritdoc + */ + public function multi(): void + { + $this->redis->multi(); + } + + /** + * @inheritdoc + */ + public function exec(): void + { + $this->redis->exec(); + } + + /** + * @inheritdoc + */ + public function discard(): void + { + $this->redis->discard(); + } + /** * @inheritdoc */ @@ -323,11 +403,11 @@ public function keys(string $pattern): array /** * @inheritdoc */ - public function search(string $prefixKey, array $search, array $orderBy, ?string $format = RedisFormat::HASH->value, ?int $numberOfResults = null, int $offset = 0, ?string $searchType = Property::INDEX_TAG): array + public function search(string $prefixKey, array $search, array $orderBy, ?string $format = RedisFormat::HASH->value, ?int $numberOfResults = null, int $offset = 0, ?string $searchType = Property::INDEX_TAG, array $rangeFilters = []): array { $arguments = [RedisCommands::SEARCH->value, Converter::prefix($prefixKey)]; - if ($search === []) { + if ($search === [] && $rangeFilters === []) { $arguments[] = '*'; } else { $criteria = ''; @@ -342,6 +422,10 @@ public function search(string $prefixKey, array $search, array $orderBy, ?string } } + foreach ($rangeFilters as $rangeQuery) { + $criteria .= $rangeQuery; + } + $arguments[] = $criteria; } diff --git a/src/Client/RedisClientInterface.php b/src/Client/RedisClientInterface.php index 4e796e6..db793be 100644 --- a/src/Client/RedisClientInterface.php +++ b/src/Client/RedisClientInterface.php @@ -20,6 +20,11 @@ public function createPersistentConnection(?string $host = null, ?int $port = nu */ public function hMSet(string $key, array $data): void; + /** + * Set a single field in a hash. + */ + public function hSet(string $key, string $field, string $value): void; + /** * Get all properties of a hash object from the Redis datastore by given key. */ @@ -50,6 +55,11 @@ public function jsonGetProperty(string $key, string $property): ?string; */ public function jsonSet(string $key, ?string $path = '$', ?string $value = '{}'): void; + /** + * Set a specific property of a JSON object. + */ + public function jsonSetProperty(string $key, string $property, string $value): void; + /** * Set multiple JSON objects to the Redis datastore. */ @@ -80,7 +90,10 @@ public function count(string $prefixKey, array $criterias = [], ?string $searchT /** * Search objects by given prefix key and criterias. */ - public function search(string $prefixKey, array $search, array $orderBy, ?string $format = RedisFormat::HASH->value, ?int $numberOfResults = null, int $offset = 0, ?string $searchType = Property::INDEX_TAG): array; + /** + * @param array $rangeFilters Pre-built NUMERIC range queries keyed by property + */ + public function search(string $prefixKey, array $search, array $orderBy, ?string $format = RedisFormat::HASH->value, ?int $numberOfResults = null, int $offset = 0, ?string $searchType = Property::INDEX_TAG, array $rangeFilters = []): array; /** * Search objects by given prefix and a complete custom query command. @@ -111,4 +124,33 @@ public function expire(string $key, int $ttl): void; * Get the expiration time as timestamp */ public function expireTime(string $key): int; + + /** + * Retrieve multiple hash objects in a single pipeline call. + * @param string[] $keys + * @return array + */ + public function hGetAllMultiple(array $keys): array; + + /** + * Retrieve multiple JSON objects in a single pipeline call. + * @param string[] $keys + * @return array + */ + public function jsonGetMultiple(array $keys): array; + + /** + * Begin a Redis transaction (MULTI). + */ + public function multi(): void; + + /** + * Execute a Redis transaction (EXEC). + */ + public function exec(): void; + + /** + * Discard a Redis transaction (DISCARD). + */ + public function discard(): void; } diff --git a/src/Command/GenerateSchema.php b/src/Command/GenerateSchema.php index 2a6a721..2fb16fc 100644 --- a/src/Command/GenerateSchema.php +++ b/src/Command/GenerateSchema.php @@ -104,6 +104,25 @@ public static function generateSchema(string $dir, ?RedisClientInterface $redisC continue; } + // Backed enums indexed based on backing type and storage format + if (is_subclass_of($propertyType, \BackedEnum::class)) { + $reflectionEnum = new \ReflectionEnum($propertyType); + $backingType = $reflectionEnum->getBackingType()?->getName(); + + if ($backingType === 'string') { + // String enums: TAG + TEXT for both HASH and JSON + $prefix = $format === RedisFormat::JSON->value ? '$.' : ''; + $propertiesToIndex[] = new PropertyToIndex($prefix . $propertyName, $propertyName, Property::INDEX_TAG); + $propertiesToIndex[] = new PropertyToIndex($prefix . $propertyName, $propertyName . '_text', Property::INDEX_TEXT); + } elseif ($backingType === 'int' && $format === RedisFormat::HASH->value) { + // Int enums in HASH: TAG + NUMERIC (Redis auto-parses strings) + $propertiesToIndex[] = new PropertyToIndex($propertyName, $propertyName, Property::INDEX_TAG); + $propertiesToIndex[] = new PropertyToIndex($propertyName, $propertyName . '_numeric', Property::INDEX_NUMERIC); + } + // Int enums in JSON: not indexable (stored as JSON number, incompatible with TAG/TEXT) + continue; + } + if (in_array($propertyType, AbstractDateTimeConverter::DATETYPES_NAMES)) { if ($format === RedisFormat::HASH->value) { $propertiesToIndex[] = new PropertyToIndex("$propertyName#timestamp", $propertyName, Property::INDEX_TAG); @@ -115,6 +134,7 @@ public static function generateSchema(string $dir, ?RedisClientInterface $redisC } elseif ($propertyType === 'int' || $propertyType === 'float') { if ($format === RedisFormat::HASH->value) { $propertiesToIndex[] = new PropertyToIndex($propertyName, $propertyName, Property::INDEX_TAG); + $propertiesToIndex[] = new PropertyToIndex($propertyName, $propertyName . '_numeric', Property::INDEX_NUMERIC); } else { $propertiesToIndex[] = new PropertyToIndex('$.' . $propertyName, $propertyName, Property::INDEX_TAG); } diff --git a/src/Om/Converters/AbstractConverterFactory.php b/src/Om/Converters/AbstractConverterFactory.php index 1db1836..23c6391 100644 --- a/src/Om/Converters/AbstractConverterFactory.php +++ b/src/Om/Converters/AbstractConverterFactory.php @@ -6,29 +6,69 @@ abstract class AbstractConverterFactory { + /** @var array */ + private static array $converterLookup = []; + + /** @var array */ + private static array $reverterLookup = []; + abstract protected static function getConvertersCollection(): array; + private static function buildCacheKey(string $type, mixed $value): string + { + $valuePart = gettype($value); + if ($value === null || $value === 'null' || $value === 'true' || $value === 'false') { + $valuePart .= ':' . var_export($value, true); + } elseif ($value instanceof \BackedEnum) { + $valuePart = 'enum'; + } + + return static::class . ':' . $type . ':' . $valuePart; + } + public static function getConverter($type, $value): ?ConverterInterface { + $cacheKey = self::buildCacheKey($type, $value); + + if (array_key_exists($cacheKey, self::$converterLookup)) { + return self::$converterLookup[$cacheKey]; + } + /** @var ConverterInterface $converter */ foreach (static::getConvertersCollection() as $converter) { if ($converter->supportsConversion($type, $value)) { + self::$converterLookup[$cacheKey] = $converter; return $converter; } } + self::$converterLookup[$cacheKey] = null; return null; } public static function getReverter(string $type, $value): ?ConverterInterface { + $cacheKey = self::buildCacheKey($type, $value); + + if (array_key_exists($cacheKey, self::$reverterLookup)) { + return self::$reverterLookup[$cacheKey]; + } + /** @var ConverterInterface $converter */ foreach (static::getConvertersCollection() as $converter) { if ($converter->supportsReversion($type, $value)) { + self::$reverterLookup[$cacheKey] = $converter; return $converter; } } + self::$reverterLookup[$cacheKey] = null; return null; } + + public static function clearCache(): void + { + self::$converterLookup = []; + self::$reverterLookup = []; + } } diff --git a/src/Om/Converters/EnumConverter.php b/src/Om/Converters/EnumConverter.php new file mode 100644 index 0000000..d3caf66 --- /dev/null +++ b/src/Om/Converters/EnumConverter.php @@ -0,0 +1,45 @@ +value; + } + + /** + * @param string|int $data + */ + public function revert($data, string $type): \BackedEnum + { + $reflectionEnum = new \ReflectionEnum($type); + $backingType = $reflectionEnum->getBackingType(); + + if ($backingType && $backingType->getName() === 'int' && is_string($data)) { + $data = (int) $data; + } + + return $type::from($data); + } + + public function supportsConversion(string $type, mixed $data): bool + { + return $data instanceof \BackedEnum; + } + + public function supportsReversion(string $type, mixed $value): bool + { + if ($value === null || $value === 'null') { + return false; + } + + return is_subclass_of($type, \BackedEnum::class); + } +} diff --git a/src/Om/Converters/HashModel/ConverterFactory.php b/src/Om/Converters/HashModel/ConverterFactory.php index 725aed9..969a626 100644 --- a/src/Om/Converters/HashModel/ConverterFactory.php +++ b/src/Om/Converters/HashModel/ConverterFactory.php @@ -7,6 +7,7 @@ use Talleu\RedisOm\Om\Converters\AbstractConverterFactory; use Talleu\RedisOm\Om\Converters\BooleanConverter; use Talleu\RedisOm\Om\Converters\ConverterInterface; +use Talleu\RedisOm\Om\Converters\EnumConverter; use Talleu\RedisOm\Om\Converters\ScalarConverter; final class ConverterFactory extends AbstractConverterFactory @@ -17,6 +18,7 @@ final class ConverterFactory extends AbstractConverterFactory protected static function getConvertersCollection(): array { return [ + new EnumConverter(), new HashObjectConverter(), new ArrayConverter(), new StandardClassConverter(), diff --git a/src/Om/Converters/JsonModel/ConverterFactory.php b/src/Om/Converters/JsonModel/ConverterFactory.php index c597257..6ca2c29 100644 --- a/src/Om/Converters/JsonModel/ConverterFactory.php +++ b/src/Om/Converters/JsonModel/ConverterFactory.php @@ -7,6 +7,7 @@ use Talleu\RedisOm\Om\Converters\AbstractConverterFactory; use Talleu\RedisOm\Om\Converters\BooleanConverter; use Talleu\RedisOm\Om\Converters\ConverterInterface; +use Talleu\RedisOm\Om\Converters\EnumConverter; use Talleu\RedisOm\Om\Converters\ScalarConverter; final class ConverterFactory extends AbstractConverterFactory @@ -17,6 +18,7 @@ final class ConverterFactory extends AbstractConverterFactory protected static function getConvertersCollection(): array { return [ + new EnumConverter(), new JsonObjectConverter(), new ArrayConverter(), new ScalarConverter(), diff --git a/src/Om/Paginator.php b/src/Om/Paginator.php new file mode 100644 index 0000000..d1ce989 --- /dev/null +++ b/src/Om/Paginator.php @@ -0,0 +1,78 @@ + + */ +class Paginator implements \IteratorAggregate, \Countable +{ + /** + * @param T[] $items + */ + public function __construct( + private readonly array $items, + private readonly int $totalItems, + private readonly int $currentPage, + private readonly int $itemsPerPage, + ) { + } + + /** + * @return T[] + */ + public function getItems(): array + { + return $this->items; + } + + public function getTotalItems(): int + { + return $this->totalItems; + } + + public function getCurrentPage(): int + { + return $this->currentPage; + } + + public function getItemsPerPage(): int + { + return $this->itemsPerPage; + } + + public function getTotalPages(): int + { + if ($this->itemsPerPage <= 0) { + return 0; + } + + return (int) ceil($this->totalItems / $this->itemsPerPage); + } + + public function hasNextPage(): bool + { + return $this->currentPage < $this->getTotalPages(); + } + + public function hasPreviousPage(): bool + { + return $this->currentPage > 1; + } + + public function count(): int + { + return count($this->items); + } + + /** + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->items); + } +} diff --git a/src/Om/Persister/HashModel/HashPersister.php b/src/Om/Persister/HashModel/HashPersister.php index 23071b2..d55da67 100644 --- a/src/Om/Persister/HashModel/HashPersister.php +++ b/src/Om/Persister/HashModel/HashPersister.php @@ -31,4 +31,17 @@ public function doDelete(array $objectsToRemove): void $this->redis->del($objectToRemove->redisKey); } } + + public function doMerge(array $objectsToMerge): void + { + foreach ($objectsToMerge as $objectToMerge) { + if ($objectToMerge->changedFields === null) { + continue; + } + + foreach ($objectToMerge->changedFields as $field => $value) { + $this->redis->hSet($objectToMerge->redisKey, $field, (string) $value); + } + } + } } diff --git a/src/Om/Persister/JsonModel/JsonPersister.php b/src/Om/Persister/JsonModel/JsonPersister.php index e55a4a5..845e2f1 100644 --- a/src/Om/Persister/JsonModel/JsonPersister.php +++ b/src/Om/Persister/JsonModel/JsonPersister.php @@ -51,4 +51,20 @@ public function doDelete(array $objectsToRemove): void $this->redis->jsonDel($objectToRemove->redisKey); } } + + /** + * @inheritdoc + */ + public function doMerge(array $objectsToMerge): void + { + foreach ($objectsToMerge as $objectToMerge) { + if ($objectToMerge->changedFields === null) { + continue; + } + + foreach ($objectToMerge->changedFields as $field => $value) { + $this->redis->jsonSetProperty($objectToMerge->redisKey, $field, \json_encode($value)); + } + } + } } diff --git a/src/Om/Persister/ObjectToPersist.php b/src/Om/Persister/ObjectToPersist.php index 158dd4a..8942f1e 100644 --- a/src/Om/Persister/ObjectToPersist.php +++ b/src/Om/Persister/ObjectToPersist.php @@ -15,6 +15,7 @@ public function __construct( public ?ConverterInterface $converter = null, public object|array|null $value = null, public ?int $ttl = null, + public ?array $changedFields = null, ) { } } diff --git a/src/Om/Persister/PersisterInterface.php b/src/Om/Persister/PersisterInterface.php index fd08431..c0e3b61 100644 --- a/src/Om/Persister/PersisterInterface.php +++ b/src/Om/Persister/PersisterInterface.php @@ -32,4 +32,10 @@ public function delete(Entity $objectMapper, $object): ObjectToPersist; * Deletes an object from redis. */ public function doDelete(array $objectsToRemove): void; + + /** + * @param ObjectToPersist[] $objectsToMerge + * Partially update objects in redis (only changed fields). + */ + public function doMerge(array $objectsToMerge): void; } diff --git a/src/Om/Persister/PersisterOperations.php b/src/Om/Persister/PersisterOperations.php index dfddd41..f6d2374 100644 --- a/src/Om/Persister/PersisterOperations.php +++ b/src/Om/Persister/PersisterOperations.php @@ -8,6 +8,7 @@ enum PersisterOperations: string { case OPERATION_PERSIST = 'doPersist'; case OPERATION_DELETE = 'doDelete'; + case OPERATION_MERGE = 'doMerge'; case OPERATION_KEY_NAME = 'operation'; case PERSISTER_KEY_NAME = 'persister'; } diff --git a/src/Om/RedisObjectManager.php b/src/Om/RedisObjectManager.php index 0188a45..7bef806 100644 --- a/src/Om/RedisObjectManager.php +++ b/src/Om/RedisObjectManager.php @@ -21,6 +21,7 @@ use Talleu\RedisOm\Om\Persister\JsonModel\JsonPersister; use Talleu\RedisOm\Om\Persister\ObjectToPersist; use Talleu\RedisOm\Om\Persister\PersisterInterface; +use Talleu\RedisOm\Om\Persister\PersisterOperations; use Talleu\RedisOm\Om\Repository\RepositoryInterface; final class RedisObjectManager implements RedisObjectManagerInterface @@ -31,6 +32,15 @@ final class RedisObjectManager implements RedisObjectManagerInterface /** @var array> */ protected array $objectsToFlush = []; + /** @var array */ + private array $entityMapperCache = []; + + /** @var array> Identity map: className -> id -> object */ + private array $identityMap = []; + + /** @var array Snapshot of converted data at load time: "class:id" -> converted array */ + private array $snapshots = []; + protected ?KeyGenerator $keyGenerator = null; private RedisClientInterface $redisClient; private EventManagerInterface $eventManager; @@ -60,12 +70,69 @@ public function persist(object $object): void $objectToPersist = $persister->persist(objectMapper: $objectMapper, object: $object); $this->objectsToFlush[$objectToPersist->persisterClass][$objectToPersist->operation][$objectToPersist->redisKey] = $objectToPersist; + // Register in identity map + $identifier = $this->keyGenerator->getIdentifier(new \ReflectionClass($object)); + $id = $object->{$identifier->getName()}; + if ($id !== null) { + $this->identityMap[get_class($object)][$id] = $object; + } + $this->eventManager->dispatchEvent( Events::POST_PERSIST, new LifecycleEventArgs($object, $this) ); } + /** + * @inheritdoc + */ + public function merge(object $object): void + { + $objectMapper = $this->getEntityMapper($object); + $identifierProperty = $this->keyGenerator->getIdentifier(new \ReflectionClass($object)); + $id = $object->{$identifierProperty->getName()}; + $className = get_class($object); + $snapshotKey = $className . ':' . $id; + + $currentData = $objectMapper->converter->convert($object); + + // If we have a snapshot, compute diff + if (isset($this->snapshots[$snapshotKey])) { + $changedFields = []; + foreach ($currentData as $field => $value) { + if (!array_key_exists($field, $this->snapshots[$snapshotKey]) || $this->snapshots[$snapshotKey][$field] !== $value) { + $changedFields[$field] = $value; + } + } + + if ($changedFields === []) { + return; // Nothing changed + } + } else { + // No snapshot = full persist (new object or not loaded via find) + $this->persist($object); + return; + } + + $persister = $this->registerPersister($objectMapper); + $key = sprintf('%s:%s', $objectMapper->prefix ?: $className, $id); + + $objectToMerge = new ObjectToPersist( + persisterClass: get_class($persister), + operation: PersisterOperations::OPERATION_MERGE->value, + redisKey: $key, + converter: $objectMapper->converter, + value: $object, + changedFields: $changedFields, + ); + + $this->objectsToFlush[$objectToMerge->persisterClass][$objectToMerge->operation][$objectToMerge->redisKey] = $objectToMerge; + + // Update snapshot + $this->snapshots[$snapshotKey] = $currentData; + $this->identityMap[$className][$id] = $object; + } + /** * @inheritdoc */ @@ -82,6 +149,11 @@ public function remove(object $object): void $objectToRemove = $persister->delete($objectMapper, $object); $this->objectsToFlush[$objectToRemove->persisterClass][$objectToRemove->operation][$objectToRemove->redisKey] = $objectToRemove; + // Remove from identity map + $identifier = $this->keyGenerator->getIdentifier(new \ReflectionClass($object)); + $id = $object->{$identifier->getName()}; + unset($this->identityMap[get_class($object)][$id]); + $this->eventManager->dispatchEvent( Events::POST_REMOVE, new LifecycleEventArgs($object, $this) @@ -93,17 +165,28 @@ public function remove(object $object): void */ public function flush(): void { - foreach ($this->objectsToFlush as $persisterClassName => $objectsByOperation) { - foreach ($objectsByOperation as $operation => $objectToPersists) { - $this->persisters[$persisterClassName]->{$operation}($objectToPersists); - foreach ($objectToPersists as $objectToPersist) { - $this->eventManager->dispatchEvent( - Events::POST_FLUSH, - new LifecycleEventArgs($objectToPersist->value, $this) - ); + if ($this->objectsToFlush === []) { + return; + } + + $this->redisClient->multi(); + try { + foreach ($this->objectsToFlush as $persisterClassName => $objectsByOperation) { + foreach ($objectsByOperation as $operation => $objectToPersists) { + $this->persisters[$persisterClassName]->{$operation}($objectToPersists); + foreach ($objectToPersists as $objectToPersist) { + $this->eventManager->dispatchEvent( + Events::POST_FLUSH, + new LifecycleEventArgs($objectToPersist->value, $this) + ); + } + unset($this->objectsToFlush[$persisterClassName][$operation]); } - unset($this->objectsToFlush[$persisterClassName][$operation]); } + $this->redisClient->exec(); + } catch (\Throwable $e) { + $this->redisClient->discard(); + throw $e; } } @@ -112,9 +195,22 @@ public function flush(): void */ public function find(string $className, $id): ?object { + // Check identity map first + if (isset($this->identityMap[$className][$id])) { + return $this->identityMap[$className][$id]; + } + $objectMapper = $this->getEntityMapper($className); + $object = $objectMapper->repository->find((string)$id); + + // Store in identity map and take snapshot for dirty tracking + if ($object !== null) { + $this->identityMap[$className][$id] = $object; + $snapshotKey = $className . ':' . $id; + $this->snapshots[$snapshotKey] = $objectMapper->converter->convert($object); + } - return $objectMapper->repository->find((string)$id); + return $object; } /** @@ -123,6 +219,8 @@ public function find(string $className, $id): ?object public function clear(): void { $this->objectsToFlush = []; + $this->identityMap = []; + $this->snapshots = []; } /** @@ -217,7 +315,13 @@ public function contains(object $object): bool protected function getEntityMapper(string|object $object): Entity { - $reflectionClass = new \ReflectionClass($object); + $className = is_string($object) ? $object : get_class($object); + + if (array_key_exists($className, $this->entityMapperCache)) { + return $this->entityMapperCache[$className]; + } + + $reflectionClass = new \ReflectionClass($className); $attributes = $reflectionClass->getAttributes(Entity::class); if ($attributes === []) { throw new \InvalidArgumentException('The object must be annotated with #[RedisOm\Entity] attribute'); @@ -233,6 +337,8 @@ protected function getEntityMapper(string|object $object): Entity $redisEntity->repository->setRedisClient($this->redisClient); $redisEntity->repository->setFormat($redisEntity->format); + $this->entityMapperCache[$className] = $redisEntity; + return $redisEntity; } diff --git a/src/Om/RedisObjectManagerInterface.php b/src/Om/RedisObjectManagerInterface.php index 98ff9bc..5a384ea 100644 --- a/src/Om/RedisObjectManagerInterface.php +++ b/src/Om/RedisObjectManagerInterface.php @@ -18,8 +18,17 @@ public function persist(object $object): void; */ public function remove(object $object): void; + /** + * Merge an object: only persist changed properties (partial update). + * The object must have been previously loaded via find(). + */ + public function merge(object $object): void; + /** * Get object by class name (FQCN) and id. + * @template T of object + * @param class-string $className + * @return T|null */ public function find(string $className, $id): ?object; @@ -45,6 +54,9 @@ public function flush(): void; /** * Get the repository for a given class name. + * @template T of object + * @param class-string $className + * @return RepositoryInterface */ public function getRepository(string $className): RepositoryInterface; diff --git a/src/Om/Repository/AbstractObjectRepository.php b/src/Om/Repository/AbstractObjectRepository.php index 0a5345b..1253ff1 100644 --- a/src/Om/Repository/AbstractObjectRepository.php +++ b/src/Om/Repository/AbstractObjectRepository.php @@ -9,6 +9,7 @@ use Talleu\RedisOm\Om\Converters\AbstractDateTimeConverter; use Talleu\RedisOm\Om\Converters\ConverterInterface; use Talleu\RedisOm\Om\Mapping\Property; +use Talleu\RedisOm\Om\Paginator; use Talleu\RedisOm\Om\QueryBuilder; use Talleu\RedisOm\Om\RedisFormat; @@ -39,6 +40,7 @@ abstract public function getPropertyValue($identifier, string $property): mixed; public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = 0): array { $limit = $this->defineLimit($limit); + $rangeFilters = $this->extractRangeFilters($criteria); $this->convertDates($criteria); $this->convertSpecial($criteria); $data = $this->redisClient->search( @@ -47,7 +49,8 @@ public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = nu $orderBy ?? [], $this->format, $limit, - offset: $offset + offset: $offset, + rangeFilters: $rangeFilters, ); $collection = []; @@ -135,6 +138,62 @@ public function findLike(string $search, ?int $limit = null): array return $collection; } + /** + * @inheritdoc + */ + public function findByGeoRadius(string $geoField, float $longitude, float $latitude, float $radius, string $unit = 'km', ?int $limit = null): array + { + $limit = $this->defineLimit($limit); + $geoQuery = sprintf('@%s:[%s %s %s %s]', $geoField, $longitude, $latitude, $radius, $unit); + + $data = $this->redisClient->search( + $this->prefix, + [], + [], + $this->format, + $limit, + rangeFilters: ['_geo' => $geoQuery], + ); + + $collection = []; + foreach ($data as $item) { + $collection[] = $this->converter->revert($item, $this->className); + } + + return $collection; + } + + /** + * @inheritdoc + */ + public function paginate(array $criteria = [], int $page = 1, int $itemsPerPage = 20, ?array $orderBy = null): Paginator + { + $page = max(1, $page); + $offset = ($page - 1) * $itemsPerPage; + + $rangeFilters = $this->extractRangeFilters($criteria); + $this->convertDates($criteria); + $this->convertSpecial($criteria); + + $totalItems = $this->redisClient->count($this->prefix, $criteria); + $data = $this->redisClient->search( + $this->prefix, + $criteria, + $orderBy ?? [], + $this->format, + $itemsPerPage, + offset: $offset, + rangeFilters: $rangeFilters, + ); + + $items = []; + foreach ($data as $item) { + $items[] = $this->converter->revert($item, $this->className); + } + + return new Paginator($items, $totalItems, $page, $itemsPerPage); + } + /** * @inheritdoc */ @@ -164,9 +223,10 @@ public function findAll(): iterable */ public function findOneBy(array $criteria, ?array $orderBy = null): ?object { + $rangeFilters = $this->extractRangeFilters($criteria); $this->convertDates($criteria); $this->convertSpecial($criteria); - $data = $this->redisClient->search($this->prefix, $criteria, $orderBy ?? [], $this->format, 1); + $data = $this->redisClient->search($this->prefix, $criteria, $orderBy ?? [], $this->format, 1, rangeFilters: $rangeFilters); if ($data === []) { return null; @@ -264,6 +324,61 @@ public function setFormat(?string $format = null): void $this->format = $format ?? RedisFormat::HASH->value; } + /** + * Extract range filters from criteria. + * Range filters use MongoDB-style operators: $gte, $gt, $lte, $lt + * Example: ['age' => ['$gte' => 18, '$lte' => 65]] โ†’ @age:[18 65] + * + * @return array Numeric range query parts keyed by property + */ + /** + * Extract range filters from criteria. + * Range filters use MongoDB-style operators: $gte, $gt, $lte, $lt + * Example: ['age' => ['$gte' => 18, '$lte' => 65]] โ†’ @age_numeric:[18 65] + * + * Requires a NUMERIC index on the property (automatic for HASH int/float, + * use #[Property(index: ['field' => 'NUMERIC'])] for JSON). + * + * @return array Numeric range query parts keyed by property + */ + protected function extractRangeFilters(array &$criteria): array + { + $rangeFilters = []; + + foreach ($criteria as $property => $value) { + if (!is_array($value)) { + continue; + } + + $rangeKeys = array_intersect(array_keys($value), ['$gte', '$gt', '$lte', '$lt']); + if (empty($rangeKeys)) { + continue; + } + + $min = '-inf'; + $max = '+inf'; + + if (isset($value['$gte'])) { + $min = (string) $value['$gte']; + } elseif (isset($value['$gt'])) { + $min = '(' . $value['$gt']; + } + + if (isset($value['$lte'])) { + $max = (string) $value['$lte']; + } elseif (isset($value['$lt'])) { + $max = '(' . $value['$lt']; + } + + // Use _numeric alias (auto-created for HASH int/float, must be explicit for JSON) + $numericAlias = $property . '_numeric'; + $rangeFilters[$property] = sprintf('@%s:[%s %s]', $numericAlias, $min, $max); + unset($criteria[$property]); + } + + return $rangeFilters; + } + protected function convertSpecial(array|string &$criteria): void { foreach ($criteria as $property => $value) { diff --git a/src/Om/Repository/HashModel/HashRepository.php b/src/Om/Repository/HashModel/HashRepository.php index d3091ff..4343156 100644 --- a/src/Om/Repository/HashModel/HashRepository.php +++ b/src/Om/Repository/HashModel/HashRepository.php @@ -26,6 +26,22 @@ public function find($identifier): ?object return $this->converter->revert($data, $this->className); } + /** + * @inheritdoc + */ + public function findMultiple(array $identifiers): array + { + $keys = array_map(fn ($id) => "$this->prefix:$id", $identifiers); + $results = $this->redisClient->hGetAllMultiple($keys); + + $objects = []; + foreach ($results as $data) { + $objects[] = $this->converter->revert($data, $this->className); + } + + return $objects; + } + /** * @inheritdoc */ diff --git a/src/Om/Repository/JsonModel/JsonRepository.php b/src/Om/Repository/JsonModel/JsonRepository.php index 060a789..26bd1cd 100644 --- a/src/Om/Repository/JsonModel/JsonRepository.php +++ b/src/Om/Repository/JsonModel/JsonRepository.php @@ -26,6 +26,24 @@ public function find($identifier): ?object return $this->converter->revert(\json_decode($data, true), $this->className); } + /** + * @inheritdoc + */ + public function findMultiple(array $identifiers): array + { + $keys = array_map(fn ($id) => "$this->prefix:$id", $identifiers); + $results = $this->redisClient->jsonGetMultiple($keys); + + $objects = []; + foreach ($results as $json) { + if ($json !== null) { + $objects[] = $this->converter->revert(\json_decode($json, true), $this->className); + } + } + + return $objects; + } + /** * @inheritdoc */ diff --git a/src/Om/Repository/RepositoryInterface.php b/src/Om/Repository/RepositoryInterface.php index 19d5119..447bac3 100644 --- a/src/Om/Repository/RepositoryInterface.php +++ b/src/Om/Repository/RepositoryInterface.php @@ -6,12 +6,17 @@ use Talleu\RedisOm\Client\RedisClientInterface; use Talleu\RedisOm\Om\Converters\ConverterInterface; +use Talleu\RedisOm\Om\Paginator; use Talleu\RedisOm\Om\QueryBuilder; +/** + * @template T of object + */ interface RepositoryInterface { /** * Find an object by its identifier. + * @return T|null */ public function find($identifier): ?object; @@ -20,53 +25,71 @@ public function find($identifier): ?object; */ public function getPropertyValue($identifier, string $property): mixed; + /** + * Find multiple objects by their identifiers using pipeline. + * @param array $identifiers + * @return T[] + */ + public function findMultiple(array $identifiers): array; + /** * Find objects by a set of criteria. - * @param array $criteria as ['property' => 'value'] - * @param array|null $orderBy as ['property' => 'ASC|DESC'] + * Supports range filters: ['age' => ['$gte' => 18, '$lte' => 65]] + * @param array $criteria as ['property' => 'value'] or range operators + * @param array|null $orderBy as ['property' => 'ASC|DESC'] + * @return T[] */ public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array; /** - * Find objects whose properties contain a given value (Case insensitive and does not need to be strictly equals) - * @param array $criteria as ['property' => 'val'] should return objects where property contains val/value/orval... - * @param array|null $orderBy as ['property' => 'ASC|DESC'] + * Find objects whose properties contain a given value (case insensitive, partial match). + * @param array $criteria + * @param array|null $orderBy + * @return T[] */ public function findByLike(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = 0): array; /** - * Find objects whose properties start with a given value (Case insensitive and does not need to be strictly equals) - * @param array $criteria as ['property' => 'val'] should return objects where property contains val/value/orval... - * @param array|null $orderBy as ['property' => 'ASC|DESC'] + * Find objects whose properties start with a given value. + * @param array $criteria + * @param array|null $orderBy + * @return T[] */ public function findByStartWith(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = 0): array; /** - * Find objects whose properties end with a given value (Case insensitive and does not need to be strictly equals) - * @param array $criteria as ['property' => 'val'] should return objects where property contains val/value/orval... - * @param array|null $orderBy as ['property' => 'ASC|DESC'] + * Find objects whose properties end with a given value. + * @param array $criteria + * @param array|null $orderBy + * @return T[] */ public function findByEndWith(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = 0): array; /** * Find objects by a full text search. + * @return T[] */ public function findLike(string $search, ?int $limit = null): array; /** * Find all objects from specific class. + * @return iterable */ public function findAll(): iterable; /** * Find one object by a set of criteria. + * @param array $criteria + * @param array|null $orderBy + * @return T|null */ public function findOneBy(array $criteria, ?array $orderBy = null): ?object; /** - * Find an object whose properties contain a given value (Case insensitive and does not need to be strictly equals) - * @param array $criteria as ['property' => 'val'] should return objects where property contains val/value/orval... - * @param array|null $orderBy as ['property' => 'ASC|DESC'] + * Find an object whose properties contain a given value (case insensitive, partial match). + * @param array $criteria + * @param array|null $orderBy + * @return T|null */ public function findOneByLike(array $criteria, ?array $orderBy = null): ?object; @@ -86,14 +109,35 @@ public function setFormat(string $format): void; /** * Count objects by a set of criteria. + * @param array $criteria */ public function count(array $criteria = []): int; /** * Count objects by a set of criteria with a "LIKE" strategy. + * @param array $criteria */ public function countByLike(array $criteria = []): int; + /** + * Paginate results with total count. + * @param array $criteria + * @param array|null $orderBy + * @return Paginator + */ + public function paginate(array $criteria = [], int $page = 1, int $itemsPerPage = 20, ?array $orderBy = null): Paginator; + + /** + * Find objects within a geographic radius. + * @param string $geoField The GEO-indexed property name + * @param float $longitude Center longitude + * @param float $latitude Center latitude + * @param float $radius Radius value + * @param string $unit Distance unit: km, m, mi, ft + * @return T[] + */ + public function findByGeoRadius(string $geoField, float $longitude, float $latitude, float $radius, string $unit = 'km', ?int $limit = null): array; + /** * Create a new QueryBuilder instance. */ diff --git a/tests/Fixtures/Hash/EnumDummyHash.php b/tests/Fixtures/Hash/EnumDummyHash.php new file mode 100644 index 0000000..8bc10ef --- /dev/null +++ b/tests/Fixtures/Hash/EnumDummyHash.php @@ -0,0 +1,26 @@ +value)] +class EnumDummyJson +{ + #[RedisOm\Id] + #[RedisOm\Property] + public ?int $id = null; + + #[RedisOm\Property(index: true)] + public string $name = ''; + + #[RedisOm\Property(index: true)] + public StatusEnum $status = StatusEnum::PENDING; + + #[RedisOm\Property] + public PriorityEnum $priority = PriorityEnum::LOW; +} diff --git a/tests/Fixtures/PriorityEnum.php b/tests/Fixtures/PriorityEnum.php new file mode 100644 index 0000000..da5e14c --- /dev/null +++ b/tests/Fixtures/PriorityEnum.php @@ -0,0 +1,12 @@ +objectManager = new RedisObjectManager(self::createRedisClient()); + parent::setUp(); + } + + public function testMergeHashPartialUpdate(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + // Load, modify one field, merge + $object = $this->objectManager->find(DummyHash::class, 1); + $originalAge = $object->age; + $object->name = 'MergedName'; + + $this->objectManager->merge($object); + $this->objectManager->flush(); + $this->objectManager->clear(); + + // Verify the change persisted and other fields untouched + $reloaded = $this->objectManager->find(DummyHash::class, 1); + $this->assertSame('MergedName', $reloaded->name); + $this->assertSame($originalAge, $reloaded->age); + } + + public function testMergeJsonPartialUpdate(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(DummyJson::class); + + $object = $this->objectManager->find(DummyJson::class, 1); + $originalAge = $object->age; + $object->name = 'MergedJson'; + + $this->objectManager->merge($object); + $this->objectManager->flush(); + $this->objectManager->clear(); + + $reloaded = $this->objectManager->find(DummyJson::class, 1); + $this->assertSame('MergedJson', $reloaded->name); + $this->assertSame($originalAge, $reloaded->age); + } + + public function testMergeNoChangeDoesNothing(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + $object = $this->objectManager->find(DummyHash::class, 1); + $originalName = $object->name; + + // Merge without changing anything + $this->objectManager->merge($object); + $this->objectManager->flush(); + $this->objectManager->clear(); + + $reloaded = $this->objectManager->find(DummyHash::class, 1); + $this->assertSame($originalName, $reloaded->name); + } + + public function testMergeNewObjectFallsToPersist(): void + { + static::emptyRedis(); + static::generateIndex(); + + $object = new DummyHash(); + $object->id = 999; + $object->name = 'BrandNew'; + $object->age = 42; + $object->price = 9.99; + + // Merge on a new object (no snapshot) should do full persist + $this->objectManager->merge($object); + $this->objectManager->flush(); + $this->objectManager->clear(); + + $found = $this->objectManager->find(DummyHash::class, 999); + $this->assertNotNull($found); + $this->assertSame('BrandNew', $found->name); + $this->assertSame(42, $found->age); + } +} diff --git a/tests/Functionnal/Om/Paginator/PaginatorTest.php b/tests/Functionnal/Om/Paginator/PaginatorTest.php new file mode 100644 index 0000000..5b512b2 --- /dev/null +++ b/tests/Functionnal/Om/Paginator/PaginatorTest.php @@ -0,0 +1,102 @@ +objectManager = new RedisObjectManager(self::createRedisClient()); + parent::setUp(); + } + + public function testPaginateHashFirstPage(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + $paginator = $repo->paginate([], page: 1, itemsPerPage: 2); + + $this->assertInstanceOf(Paginator::class, $paginator); + $this->assertSame(3, $paginator->getTotalItems()); + $this->assertSame(1, $paginator->getCurrentPage()); + $this->assertSame(2, $paginator->getItemsPerPage()); + $this->assertCount(2, $paginator->getItems()); + $this->assertTrue($paginator->hasNextPage()); + $this->assertFalse($paginator->hasPreviousPage()); + $this->assertSame(2, $paginator->getTotalPages()); + } + + public function testPaginateHashSecondPage(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + $paginator = $repo->paginate([], page: 2, itemsPerPage: 2); + + $this->assertCount(1, $paginator->getItems()); + $this->assertSame(2, $paginator->getCurrentPage()); + $this->assertFalse($paginator->hasNextPage()); + $this->assertTrue($paginator->hasPreviousPage()); + } + + public function testPaginateWithCriteria(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + $paginator = $repo->paginate(['name' => 'Olivier'], page: 1, itemsPerPage: 10); + + $this->assertSame(2, $paginator->getTotalItems()); + $this->assertCount(2, $paginator->getItems()); + foreach ($paginator as $item) { + $this->assertSame('Olivier', $item->name); + } + } + + public function testPaginateJson(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(DummyJson::class); + + $repo = $this->objectManager->getRepository(DummyJson::class); + $paginator = $repo->paginate([], page: 1, itemsPerPage: 2); + + $this->assertSame(3, $paginator->getTotalItems()); + $this->assertCount(2, $paginator->getItems()); + foreach ($paginator->getItems() as $item) { + $this->assertInstanceOf(DummyJson::class, $item); + } + } + + public function testPaginateEmptyResult(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + $paginator = $repo->paginate(['name' => 'NonExistent']); + + $this->assertSame(0, $paginator->getTotalItems()); + $this->assertEmpty($paginator->getItems()); + $this->assertSame(0, $paginator->getTotalPages()); + } +} diff --git a/tests/Functionnal/Om/Repository/HashModel/EnumRepositoryTest.php b/tests/Functionnal/Om/Repository/HashModel/EnumRepositoryTest.php new file mode 100644 index 0000000..72eec94 --- /dev/null +++ b/tests/Functionnal/Om/Repository/HashModel/EnumRepositoryTest.php @@ -0,0 +1,105 @@ +objectManager = new RedisObjectManager(self::createRedisClient()); + parent::setUp(); + } + + public function testPersistAndFindWithEnums(): void + { + static::emptyRedis(); + static::generateIndex(); + + $entity = new EnumDummyHash(); + $entity->id = 1; + $entity->name = 'Task 1'; + $entity->status = StatusEnum::ACTIVE; + $entity->priority = PriorityEnum::HIGH; + + $this->objectManager->persist($entity); + $this->objectManager->flush(); + $this->objectManager->clear(); + + $found = $this->objectManager->find(EnumDummyHash::class, 1); + + $this->assertInstanceOf(EnumDummyHash::class, $found); + $this->assertSame(StatusEnum::ACTIVE, $found->status); + $this->assertSame(PriorityEnum::HIGH, $found->priority); + $this->assertSame('Task 1', $found->name); + } + + public function testFindByEnum(): void + { + static::emptyRedis(); + static::generateIndex(); + + $entity1 = new EnumDummyHash(); + $entity1->id = 1; + $entity1->name = 'Active task'; + $entity1->status = StatusEnum::ACTIVE; + $entity1->priority = PriorityEnum::LOW; + + $entity2 = new EnumDummyHash(); + $entity2->id = 2; + $entity2->name = 'Inactive task'; + $entity2->status = StatusEnum::INACTIVE; + $entity2->priority = PriorityEnum::HIGH; + + $entity3 = new EnumDummyHash(); + $entity3->id = 3; + $entity3->name = 'Another active'; + $entity3->status = StatusEnum::ACTIVE; + $entity3->priority = PriorityEnum::MEDIUM; + + $this->objectManager->persist($entity1); + $this->objectManager->persist($entity2); + $this->objectManager->persist($entity3); + $this->objectManager->flush(); + $this->objectManager->clear(); + + $repo = $this->objectManager->getRepository(EnumDummyHash::class); + $activeEntities = $repo->findBy(['status' => 'active']); + + $this->assertCount(2, $activeEntities); + foreach ($activeEntities as $entity) { + $this->assertSame(StatusEnum::ACTIVE, $entity->status); + } + } + + public function testEnumRoundTripAllValues(): void + { + static::emptyRedis(); + static::generateIndex(); + + foreach (StatusEnum::cases() as $i => $status) { + $entity = new EnumDummyHash(); + $entity->id = $i + 10; + $entity->name = "Status: {$status->value}"; + $entity->status = $status; + $entity->priority = PriorityEnum::LOW; + $this->objectManager->persist($entity); + } + $this->objectManager->flush(); + $this->objectManager->clear(); + + foreach (StatusEnum::cases() as $i => $status) { + $found = $this->objectManager->find(EnumDummyHash::class, $i + 10); + $this->assertSame($status, $found->status); + } + } +} diff --git a/tests/Functionnal/Om/Repository/HashModel/FindMultipleTest.php b/tests/Functionnal/Om/Repository/HashModel/FindMultipleTest.php new file mode 100644 index 0000000..a3203bb --- /dev/null +++ b/tests/Functionnal/Om/Repository/HashModel/FindMultipleTest.php @@ -0,0 +1,59 @@ +objectManager = new RedisObjectManager(self::createRedisClient()); + parent::setUp(); + } + + public function testFindMultiple(): void + { + static::emptyRedis(); + static::generateIndex(); + $fixtures = static::loadRedisFixtures(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + $results = $repo->findMultiple([1, 3]); + + $this->assertCount(2, $results); + $ids = array_map(fn ($r) => $r->id, $results); + $this->assertContains(1, $ids); + $this->assertContains(3, $ids); + } + + public function testFindMultipleWithMissingIds(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + $results = $repo->findMultiple([1, 999, 3]); + + // 999 doesn't exist, should return only 2 results + $this->assertCount(2, $results); + } + + public function testFindMultipleEmpty(): void + { + static::emptyRedis(); + static::generateIndex(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + $results = $repo->findMultiple([]); + + $this->assertEmpty($results); + } +} diff --git a/tests/Functionnal/Om/Repository/HashModel/RangeQueryTest.php b/tests/Functionnal/Om/Repository/HashModel/RangeQueryTest.php new file mode 100644 index 0000000..71e9cd3 --- /dev/null +++ b/tests/Functionnal/Om/Repository/HashModel/RangeQueryTest.php @@ -0,0 +1,98 @@ +objectManager = new RedisObjectManager(self::createRedisClient()); + parent::setUp(); + } + + public function testFindByGteAndLte(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + + // Fixtures: ages 20, 18, 34 + $results = $repo->findBy(['age' => ['$gte' => 19, '$lte' => 34]]); + + $this->assertCount(2, $results); + foreach ($results as $result) { + $this->assertGreaterThanOrEqual(19, $result->age); + $this->assertLessThanOrEqual(34, $result->age); + } + } + + public function testFindByGtOnly(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + + // age > 20 โ†’ should return age=34 only (gt is exclusive) + $results = $repo->findBy(['age' => ['$gt' => 20]]); + + $this->assertCount(1, $results); + $this->assertSame(34, $results[0]->age); + } + + public function testFindByLtOnly(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + + // age < 20 โ†’ should return age=18 only + $results = $repo->findBy(['age' => ['$lt' => 20]]); + + $this->assertCount(1, $results); + $this->assertSame(18, $results[0]->age); + } + + public function testFindByRangeWithOtherCriteria(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + + // name=Olivier AND age >= 20 + $results = $repo->findBy(['name' => 'Olivier', 'age' => ['$gte' => 20]]); + + $this->assertCount(2, $results); + foreach ($results as $result) { + $this->assertSame('Olivier', $result->name); + $this->assertGreaterThanOrEqual(20, $result->age); + } + } + + public function testFindByRangeNoResults(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(); + + $repo = $this->objectManager->getRepository(DummyHash::class); + + $results = $repo->findBy(['age' => ['$gte' => 100]]); + $this->assertEmpty($results); + } +} diff --git a/tests/Functionnal/Om/Repository/JsonModel/EnumRepositoryTest.php b/tests/Functionnal/Om/Repository/JsonModel/EnumRepositoryTest.php new file mode 100644 index 0000000..5880ba1 --- /dev/null +++ b/tests/Functionnal/Om/Repository/JsonModel/EnumRepositoryTest.php @@ -0,0 +1,92 @@ +objectManager = new RedisObjectManager(self::createRedisClient()); + parent::setUp(); + } + + public function testPersistAndFindWithEnums(): void + { + static::emptyRedis(); + static::generateIndex(); + + $entity = new EnumDummyJson(); + $entity->id = 1; + $entity->name = 'Task 1'; + $entity->status = StatusEnum::ACTIVE; + $entity->priority = PriorityEnum::HIGH; + + $this->objectManager->persist($entity); + $this->objectManager->flush(); + $this->objectManager->clear(); + + $found = $this->objectManager->find(EnumDummyJson::class, 1); + + $this->assertInstanceOf(EnumDummyJson::class, $found); + $this->assertSame(StatusEnum::ACTIVE, $found->status); + $this->assertSame(PriorityEnum::HIGH, $found->priority); + } + + public function testFindByEnum(): void + { + static::emptyRedis(); + static::generateIndex(); + + $entity1 = new EnumDummyJson(); + $entity1->id = 1; + $entity1->name = 'Active'; + $entity1->status = StatusEnum::ACTIVE; + + $entity2 = new EnumDummyJson(); + $entity2->id = 2; + $entity2->name = 'Inactive'; + $entity2->status = StatusEnum::INACTIVE; + + $this->objectManager->persist($entity1); + $this->objectManager->persist($entity2); + $this->objectManager->flush(); + $this->objectManager->clear(); + + $repo = $this->objectManager->getRepository(EnumDummyJson::class); + $results = $repo->findBy(['status' => 'active']); + + $this->assertCount(1, $results); + $this->assertSame(StatusEnum::ACTIVE, $results[0]->status); + } + + public function testEnumRoundTripAllValues(): void + { + static::emptyRedis(); + static::generateIndex(); + + foreach (StatusEnum::cases() as $i => $status) { + $entity = new EnumDummyJson(); + $entity->id = $i + 10; + $entity->name = $status->value; + $entity->status = $status; + $this->objectManager->persist($entity); + } + $this->objectManager->flush(); + $this->objectManager->clear(); + + foreach (StatusEnum::cases() as $i => $status) { + $found = $this->objectManager->find(EnumDummyJson::class, $i + 10); + $this->assertSame($status, $found->status); + } + } +} diff --git a/tests/Functionnal/Om/Repository/JsonModel/FindMultipleTest.php b/tests/Functionnal/Om/Repository/JsonModel/FindMultipleTest.php new file mode 100644 index 0000000..0d5909e --- /dev/null +++ b/tests/Functionnal/Om/Repository/JsonModel/FindMultipleTest.php @@ -0,0 +1,47 @@ +objectManager = new RedisObjectManager(self::createRedisClient()); + parent::setUp(); + } + + public function testFindMultiple(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(DummyJson::class); + + $repo = $this->objectManager->getRepository(DummyJson::class); + $results = $repo->findMultiple([1, 2, 3]); + + $this->assertCount(3, $results); + foreach ($results as $result) { + $this->assertInstanceOf(DummyJson::class, $result); + } + } + + public function testFindMultipleWithMissingIds(): void + { + static::emptyRedis(); + static::generateIndex(); + static::loadRedisFixtures(DummyJson::class); + + $repo = $this->objectManager->getRepository(DummyJson::class); + $results = $repo->findMultiple([1, 999]); + + $this->assertCount(1, $results); + } +} diff --git a/tests/Functionnal/Om/Repository/JsonModel/RangeQueryTest.php b/tests/Functionnal/Om/Repository/JsonModel/RangeQueryTest.php new file mode 100644 index 0000000..782015a --- /dev/null +++ b/tests/Functionnal/Om/Repository/JsonModel/RangeQueryTest.php @@ -0,0 +1,20 @@ +markTestSkipped('Range queries on JSON require explicit NUMERIC index. Use HASH or #[Property(index: [\'field\' => \'NUMERIC\'])].'); + } +} diff --git a/tests/Unit/Om/Converters/EnumConverterTest.php b/tests/Unit/Om/Converters/EnumConverterTest.php new file mode 100644 index 0000000..395d826 --- /dev/null +++ b/tests/Unit/Om/Converters/EnumConverterTest.php @@ -0,0 +1,144 @@ +converter = new EnumConverter(); + HashConverterFactory::clearCache(); + JsonConverterFactory::clearCache(); + } + + public function testConvertStringEnum(): void + { + $result = $this->converter->convert(StatusEnum::ACTIVE); + $this->assertSame('active', $result); + } + + public function testConvertIntEnum(): void + { + $result = $this->converter->convert(PriorityEnum::HIGH); + $this->assertSame(3, $result); + } + + public function testRevertStringEnum(): void + { + $result = $this->converter->revert('inactive', StatusEnum::class); + $this->assertSame(StatusEnum::INACTIVE, $result); + } + + public function testRevertIntEnum(): void + { + $result = $this->converter->revert(2, PriorityEnum::class); + $this->assertSame(PriorityEnum::MEDIUM, $result); + } + + public function testRevertIntEnumFromString(): void + { + // Redis stores everything as strings in HASH model + $result = $this->converter->revert('1', PriorityEnum::class); + $this->assertSame(PriorityEnum::LOW, $result); + } + + public function testRevertInvalidValueThrows(): void + { + $this->expectException(\ValueError::class); + $this->converter->revert('nonexistent', StatusEnum::class); + } + + public function testSupportsConversionForBackedEnum(): void + { + $this->assertTrue($this->converter->supportsConversion(StatusEnum::class, StatusEnum::ACTIVE)); + $this->assertTrue($this->converter->supportsConversion(PriorityEnum::class, PriorityEnum::HIGH)); + } + + public function testDoesNotSupportConversionForNonEnum(): void + { + $this->assertFalse($this->converter->supportsConversion('string', 'hello')); + $this->assertFalse($this->converter->supportsConversion('int', 42)); + $this->assertFalse($this->converter->supportsConversion('bool', true)); + } + + public function testSupportsReversionForBackedEnum(): void + { + $this->assertTrue($this->converter->supportsReversion(StatusEnum::class, 'active')); + $this->assertTrue($this->converter->supportsReversion(PriorityEnum::class, 1)); + } + + public function testDoesNotSupportReversionForNull(): void + { + $this->assertFalse($this->converter->supportsReversion(StatusEnum::class, null)); + $this->assertFalse($this->converter->supportsReversion(StatusEnum::class, 'null')); + } + + public function testDoesNotSupportReversionForNonEnumType(): void + { + $this->assertFalse($this->converter->supportsReversion('string', 'active')); + $this->assertFalse($this->converter->supportsReversion(\stdClass::class, 'test')); + } + + public function testRoundTripStringEnum(): void + { + $original = StatusEnum::PENDING; + $converted = $this->converter->convert($original); + $reverted = $this->converter->revert($converted, StatusEnum::class); + $this->assertSame($original, $reverted); + } + + public function testRoundTripIntEnum(): void + { + $original = PriorityEnum::MEDIUM; + $converted = $this->converter->convert($original); + $reverted = $this->converter->revert($converted, PriorityEnum::class); + $this->assertSame($original, $reverted); + } + + public function testHashConverterFactoryFindsEnumConverter(): void + { + $converter = HashConverterFactory::getConverter(StatusEnum::class, StatusEnum::ACTIVE); + $this->assertInstanceOf(EnumConverter::class, $converter); + } + + public function testJsonConverterFactoryFindsEnumConverter(): void + { + $converter = JsonConverterFactory::getConverter(StatusEnum::class, StatusEnum::ACTIVE); + $this->assertInstanceOf(EnumConverter::class, $converter); + } + + public function testHashConverterFactoryFindsEnumReverter(): void + { + $reverter = HashConverterFactory::getReverter(StatusEnum::class, 'active'); + $this->assertInstanceOf(EnumConverter::class, $reverter); + } + + public function testJsonConverterFactoryFindsEnumReverter(): void + { + $reverter = JsonConverterFactory::getReverter(StatusEnum::class, 'active'); + $this->assertInstanceOf(EnumConverter::class, $reverter); + } +} diff --git a/tests/Unit/Om/Converters/HashModel/ConverterFactoryTest.php b/tests/Unit/Om/Converters/HashModel/ConverterFactoryTest.php index d849515..7aa3326 100644 --- a/tests/Unit/Om/Converters/HashModel/ConverterFactoryTest.php +++ b/tests/Unit/Om/Converters/HashModel/ConverterFactoryTest.php @@ -17,6 +17,11 @@ final class ConverterFactoryTest extends TestCase { + protected function setUp(): void + { + ConverterFactory::clearCache(); + } + public function testGetConverterForInt(): void { $converter = ConverterFactory::getConverter('int', 42); diff --git a/tests/Unit/Om/MergeTest.php b/tests/Unit/Om/MergeTest.php new file mode 100644 index 0000000..1c5fa4d --- /dev/null +++ b/tests/Unit/Om/MergeTest.php @@ -0,0 +1,151 @@ +createMock(RedisClientInterface::class); + $redisClient->method('hGetAll')->willReturn(['id' => '1', 'name' => 'Original', 'age' => '25']); + $redisClient->method('multi'); + $redisClient->method('exec'); + + // Should call hSet for only the changed field, not hMSet for full object + $redisClient->expects($this->once()) + ->method('hSet') + ->with($this->anything(), 'name', 'Updated'); + $redisClient->expects($this->never()) + ->method('hMSet'); + + $om = new RedisObjectManager($redisClient); + + // Load object (creates snapshot) + /** @var MergeTestHashEntity $object */ + $object = $om->find(MergeTestHashEntity::class, 1); + $this->assertSame('Original', $object->name); + + // Modify only name + $object->name = 'Updated'; + + // Merge instead of persist + $om->merge($object); + $om->flush(); + } + + public function testMergeOnlyUpdatesChangedFieldsJson(): void + { + $redisClient = $this->createMock(RedisClientInterface::class); + $redisClient->method('jsonGet')->willReturn('{"id":"1","name":"Original","age":"25"}'); + $redisClient->method('multi'); + $redisClient->method('exec'); + + $redisClient->expects($this->once()) + ->method('jsonSetProperty') + ->with($this->anything(), 'name', '"Updated"'); + $redisClient->expects($this->never()) + ->method('jsonSet'); + + $om = new RedisObjectManager($redisClient); + + /** @var MergeTestJsonEntity $object */ + $object = $om->find(MergeTestJsonEntity::class, 1); + $object->name = 'Updated'; + + $om->merge($object); + $om->flush(); + } + + public function testMergeSkipsWhenNothingChanged(): void + { + $redisClient = $this->createMock(RedisClientInterface::class); + $redisClient->method('hGetAll')->willReturn(['id' => '1', 'name' => 'Same', 'age' => '25']); + + // Should not call any write methods + $redisClient->expects($this->never())->method('hSet'); + $redisClient->expects($this->never())->method('hMSet'); + $redisClient->expects($this->never())->method('multi'); + + $om = new RedisObjectManager($redisClient); + $object = $om->find(MergeTestHashEntity::class, 1); + + // Don't change anything + $om->merge($object); + $om->flush(); + } + + public function testMergeFallsBackToPersistForNewObjects(): void + { + $redisClient = $this->createMock(RedisClientInterface::class); + $redisClient->method('multi'); + $redisClient->method('exec'); + + // New object should do full persist via hMSet + $redisClient->expects($this->once())->method('hMSet'); + + $om = new RedisObjectManager($redisClient); + + $object = new MergeTestHashEntity(); + $object->id = 99; + $object->name = 'New'; + $object->age = 30; + + $om->merge($object); + $om->flush(); + } + + public function testMergeMultipleFieldsChanged(): void + { + $redisClient = $this->createMock(RedisClientInterface::class); + $redisClient->method('hGetAll')->willReturn(['id' => '1', 'name' => 'Old', 'age' => '20']); + $redisClient->method('multi'); + $redisClient->method('exec'); + + $redisClient->expects($this->exactly(2))->method('hSet'); + + $om = new RedisObjectManager($redisClient); + $object = $om->find(MergeTestHashEntity::class, 1); + + $object->name = 'New'; + $object->age = 99; + + $om->merge($object); + $om->flush(); + } +} + +#[RedisOm\Entity] +class MergeTestHashEntity +{ + #[RedisOm\Id] + #[RedisOm\Property] + public ?int $id = null; + + #[RedisOm\Property(index: true)] + public string $name = ''; + + #[RedisOm\Property(index: true)] + public int $age = 0; +} + +#[RedisOm\Entity(format: RedisFormat::JSON->value)] +class MergeTestJsonEntity +{ + #[RedisOm\Id] + #[RedisOm\Property] + public ?int $id = null; + + #[RedisOm\Property(index: true)] + public string $name = ''; + + #[RedisOm\Property(index: true)] + public int $age = 0; +} diff --git a/tests/Unit/Om/PaginatorTest.php b/tests/Unit/Om/PaginatorTest.php new file mode 100644 index 0000000..546863e --- /dev/null +++ b/tests/Unit/Om/PaginatorTest.php @@ -0,0 +1,156 @@ +assertSame(50, $paginator->getTotalItems()); + $this->assertSame(1, $paginator->getCurrentPage()); + $this->assertSame(10, $paginator->getItemsPerPage()); + $this->assertSame(5, $paginator->getTotalPages()); + $this->assertCount(2, $paginator->getItems()); + } + + public function testHasNextPage(): void + { + $paginator = new Paginator([], 50, 1, 10); + $this->assertTrue($paginator->hasNextPage()); + + $paginator = new Paginator([], 50, 5, 10); + $this->assertFalse($paginator->hasNextPage()); + } + + public function testHasPreviousPage(): void + { + $paginator = new Paginator([], 50, 1, 10); + $this->assertFalse($paginator->hasPreviousPage()); + + $paginator = new Paginator([], 50, 3, 10); + $this->assertTrue($paginator->hasPreviousPage()); + } + + public function testTotalPagesRoundsUp(): void + { + $paginator = new Paginator([], 51, 1, 10); + $this->assertSame(6, $paginator->getTotalPages()); + + $paginator = new Paginator([], 50, 1, 10); + $this->assertSame(5, $paginator->getTotalPages()); + + $paginator = new Paginator([], 1, 1, 10); + $this->assertSame(1, $paginator->getTotalPages()); + } + + public function testEmptyPaginator(): void + { + $paginator = new Paginator([], 0, 1, 10); + + $this->assertSame(0, $paginator->getTotalItems()); + $this->assertSame(0, $paginator->getTotalPages()); + $this->assertFalse($paginator->hasNextPage()); + $this->assertFalse($paginator->hasPreviousPage()); + $this->assertCount(0, $paginator); + } + + public function testCountable(): void + { + $items = [new \stdClass(), new \stdClass(), new \stdClass()]; + $paginator = new Paginator($items, 100, 1, 10); + + $this->assertCount(3, $paginator); + } + + public function testIterable(): void + { + $items = [new \stdClass(), new \stdClass()]; + $paginator = new Paginator($items, 100, 1, 10); + + $iterated = []; + foreach ($paginator as $item) { + $iterated[] = $item; + } + + $this->assertCount(2, $iterated); + } + + public function testRepositoryPaginate(): void + { + $redisClient = $this->createMock(RedisClientInterface::class); + $redisClient->expects($this->once()) + ->method('count') + ->willReturn(25); + $redisClient->expects($this->once()) + ->method('search') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->equalTo(10), + $this->equalTo(10), // offset = (page-1) * perPage = (2-1)*10 = 10 + ) + ->willReturn([ + ['id' => '11', 'name' => 'Page2-1'], + ['id' => '12', 'name' => 'Page2-2'], + ]); + + $om = new RedisObjectManager($redisClient); + $repo = $om->getRepository(PaginatorTestEntity::class); + + $paginator = $repo->paginate([], page: 2, itemsPerPage: 10); + + $this->assertInstanceOf(Paginator::class, $paginator); + $this->assertSame(25, $paginator->getTotalItems()); + $this->assertSame(2, $paginator->getCurrentPage()); + $this->assertSame(10, $paginator->getItemsPerPage()); + $this->assertSame(3, $paginator->getTotalPages()); + $this->assertTrue($paginator->hasNextPage()); + $this->assertTrue($paginator->hasPreviousPage()); + $this->assertCount(2, $paginator->getItems()); + } + + public function testRepositoryPaginatePageOneNoOffset(): void + { + $redisClient = $this->createMock(RedisClientInterface::class); + $redisClient->method('count')->willReturn(5); + $redisClient->expects($this->once()) + ->method('search') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->equalTo(20), + $this->equalTo(0), // offset = 0 for page 1 + ) + ->willReturn([]); + + $om = new RedisObjectManager($redisClient); + $repo = $om->getRepository(PaginatorTestEntity::class); + $repo->paginate([], page: 1, itemsPerPage: 20); + } +} + +#[RedisOm\Entity] +class PaginatorTestEntity +{ + #[RedisOm\Id] + #[RedisOm\Property] + public ?int $id = null; + + #[RedisOm\Property(index: true)] + public string $name = ''; +} diff --git a/tests/Unit/Om/RedisObjectManagerTest.php b/tests/Unit/Om/RedisObjectManagerTest.php index 642e69e..7c3031e 100644 --- a/tests/Unit/Om/RedisObjectManagerTest.php +++ b/tests/Unit/Om/RedisObjectManagerTest.php @@ -173,6 +173,60 @@ public function testGetEventManager(): void $this->assertNotNull($this->objectManager->getEventManager()); } + public function testIdentityMapReturnsSameObjectOnSecondFind(): void + { + $this->redisClient->expects($this->once()) + ->method('hGetAll') + ->willReturn(['id' => '1', 'name' => 'cached']); + + $result1 = $this->objectManager->find(OMTestHashEntity::class, 1); + $result2 = $this->objectManager->find(OMTestHashEntity::class, 1); + + $this->assertSame($result1, $result2); + } + + public function testIdentityMapPopulatedByPersist(): void + { + $this->redisClient->method('hMSet'); + $this->redisClient->expects($this->never())->method('hGetAll'); + + $object = new OMTestHashEntity(); + $object->id = 42; + $object->name = 'persisted'; + + $this->objectManager->persist($object); + $this->objectManager->flush(); + + $found = $this->objectManager->find(OMTestHashEntity::class, 42); + $this->assertSame($object, $found); + } + + public function testIdentityMapClearedOnClear(): void + { + $this->redisClient->expects($this->exactly(2)) + ->method('hGetAll') + ->willReturn(['id' => '1', 'name' => 'test']); + + $this->objectManager->find(OMTestHashEntity::class, 1); + $this->objectManager->clear(); + $this->objectManager->find(OMTestHashEntity::class, 1); + } + + public function testIdentityMapRemovedOnRemove(): void + { + $this->redisClient->method('del'); + $this->redisClient->expects($this->exactly(2)) + ->method('hGetAll') + ->willReturn(['id' => '1', 'name' => 'test']); + + $result = $this->objectManager->find(OMTestHashEntity::class, 1); + $this->objectManager->remove($result); + $this->objectManager->flush(); + + // Should hit Redis again since removed from identity map + $this->objectManager->find(OMTestHashEntity::class, 1); + } + public function testPersistWithNullIdGeneratesOne(): void { $this->redisClient->method('hMSet'); diff --git a/tests/Unit/Om/Repository/FindMultipleTest.php b/tests/Unit/Om/Repository/FindMultipleTest.php new file mode 100644 index 0000000..4400f8b --- /dev/null +++ b/tests/Unit/Om/Repository/FindMultipleTest.php @@ -0,0 +1,91 @@ +createMock(RedisClientInterface::class); + $redisClient->expects($this->once()) + ->method('hGetAllMultiple') + ->willReturn([ + 'key:1' => ['id' => '1', 'name' => 'First'], + 'key:3' => ['id' => '3', 'name' => 'Third'], + ]); + + $om = new RedisObjectManager($redisClient); + $repo = $om->getRepository(FindMultipleHashEntity::class); + $results = $repo->findMultiple([1, 2, 3]); + + $this->assertCount(2, $results); + $this->assertSame(1, $results[0]->id); + $this->assertSame('First', $results[0]->name); + $this->assertSame(3, $results[1]->id); + } + + public function testFindMultipleJson(): void + { + $redisClient = $this->createMock(RedisClientInterface::class); + $redisClient->expects($this->once()) + ->method('jsonGetMultiple') + ->willReturn([ + 'key:1' => '{"id":"1","name":"First"}', + 'key:2' => null, + 'key:3' => '{"id":"3","name":"Third"}', + ]); + + $om = new RedisObjectManager($redisClient); + $repo = $om->getRepository(FindMultipleJsonEntity::class); + $results = $repo->findMultiple([1, 2, 3]); + + $this->assertCount(2, $results); + $this->assertSame(1, $results[0]->id); + $this->assertSame(3, $results[1]->id); + } + + public function testFindMultipleEmpty(): void + { + $redisClient = $this->createMock(RedisClientInterface::class); + $redisClient->expects($this->once()) + ->method('hGetAllMultiple') + ->with([]) + ->willReturn([]); + + $om = new RedisObjectManager($redisClient); + $repo = $om->getRepository(FindMultipleHashEntity::class); + $results = $repo->findMultiple([]); + + $this->assertEmpty($results); + } +} + +#[RedisOm\Entity] +class FindMultipleHashEntity +{ + #[RedisOm\Id] + #[RedisOm\Property] + public ?int $id = null; + + #[RedisOm\Property] + public string $name = ''; +} + +#[RedisOm\Entity(format: RedisFormat::JSON->value)] +class FindMultipleJsonEntity +{ + #[RedisOm\Id] + #[RedisOm\Property] + public ?int $id = null; + + #[RedisOm\Property] + public string $name = ''; +} diff --git a/tests/Unit/Om/Repository/GeoQueryTest.php b/tests/Unit/Om/Repository/GeoQueryTest.php new file mode 100644 index 0000000..6b8e586 --- /dev/null +++ b/tests/Unit/Om/Repository/GeoQueryTest.php @@ -0,0 +1,79 @@ +createMock(RedisClientInterface::class); + $redisClient->expects($this->once()) + ->method('search') + ->with( + $this->anything(), + $this->equalTo([]), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->equalTo(['_geo' => '@location:[2.3522 48.8566 10 km]']), + ) + ->willReturn([ + ['id' => '1', 'name' => 'Paris Place', 'location' => '2.35,48.85'], + ]); + + $om = new RedisObjectManager($redisClient); + $repo = $om->getRepository(GeoTestEntity::class); + + $results = $repo->findByGeoRadius('location', 2.3522, 48.8566, 10, 'km'); + + $this->assertCount(1, $results); + $this->assertInstanceOf(GeoTestEntity::class, $results[0]); + } + + public function testFindByGeoRadiusWithMiles(): void + { + $redisClient = $this->createMock(RedisClientInterface::class); + $redisClient->expects($this->once()) + ->method('search') + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->equalTo(['_geo' => '@location:[-73.9857 40.7484 5 mi]']), + ) + ->willReturn([]); + + $om = new RedisObjectManager($redisClient); + $repo = $om->getRepository(GeoTestEntity::class); + + $results = $repo->findByGeoRadius('location', -73.9857, 40.7484, 5, 'mi'); + $this->assertEmpty($results); + } +} + +#[RedisOm\Entity] +class GeoTestEntity +{ + #[RedisOm\Id] + #[RedisOm\Property] + public ?int $id = null; + + #[RedisOm\Property(index: true)] + public string $name = ''; + + #[RedisOm\Property(index: ['location' => 'GEO'])] + public string $location = ''; +} diff --git a/tests/Unit/Om/Repository/RangeQueryTest.php b/tests/Unit/Om/Repository/RangeQueryTest.php new file mode 100644 index 0000000..17d9359 --- /dev/null +++ b/tests/Unit/Om/Repository/RangeQueryTest.php @@ -0,0 +1,165 @@ +redisClient = $this->createMock(RedisClientInterface::class); + $this->objectManager = new RedisObjectManager($this->redisClient); + } + + public function testFindByWithGteAndLte(): void + { + $this->redisClient->expects($this->once()) + ->method('search') + ->with( + $this->anything(), + $this->equalTo([]), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->equalTo(['age' => '@age_numeric:[18 65]']), + ) + ->willReturn([]); + + $repo = $this->objectManager->getRepository(RangeTestEntity::class); + $repo->findBy(['age' => ['$gte' => 18, '$lte' => 65]]); + } + + public function testFindByWithGtOnly(): void + { + $this->redisClient->expects($this->once()) + ->method('search') + ->with( + $this->anything(), + $this->equalTo([]), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->equalTo(['price' => '@price_numeric:[(10 +inf]']), + ) + ->willReturn([]); + + $repo = $this->objectManager->getRepository(RangeTestEntity::class); + $repo->findBy(['price' => ['$gt' => 10]]); + } + + public function testFindByWithLtOnly(): void + { + $this->redisClient->expects($this->once()) + ->method('search') + ->with( + $this->anything(), + $this->equalTo([]), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->equalTo(['age' => '@age_numeric:[-inf (100]']), + ) + ->willReturn([]); + + $repo = $this->objectManager->getRepository(RangeTestEntity::class); + $repo->findBy(['age' => ['$lt' => 100]]); + } + + public function testFindByMixesRangeAndExactCriteria(): void + { + $this->redisClient->expects($this->once()) + ->method('search') + ->with( + $this->anything(), + $this->equalTo(['name' => 'John']), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->equalTo(['age' => '@age_numeric:[18 +inf]']), + ) + ->willReturn([]); + + $repo = $this->objectManager->getRepository(RangeTestEntity::class); + $repo->findBy(['name' => 'John', 'age' => ['$gte' => 18]]); + } + + public function testFindOneByWithRange(): void + { + $this->redisClient->expects($this->once()) + ->method('search') + ->with( + $this->anything(), + $this->equalTo([]), + $this->anything(), + $this->anything(), + $this->equalTo(1), + $this->anything(), + $this->anything(), + $this->equalTo(['age' => '@age_numeric:[0 17]']), + ) + ->willReturn([]); + + $repo = $this->objectManager->getRepository(RangeTestEntity::class); + $repo->findOneBy(['age' => ['$gte' => 0, '$lte' => 17]]); + } + + public function testNonRangeArrayCriteriaPassedThrough(): void + { + // Arrays without $gte/$lte/$gt/$lt should not be treated as ranges + $this->redisClient->expects($this->once()) + ->method('search') + ->with( + $this->anything(), + $this->equalTo(['tags' => ['php', 'redis']]), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->equalTo([]), + ) + ->willReturn([]); + + $repo = $this->objectManager->getRepository(RangeTestEntity::class); + $repo->findBy(['tags' => ['php', 'redis']]); + } +} + +#[RedisOm\Entity] +class RangeTestEntity +{ + #[RedisOm\Id] + #[RedisOm\Property] + public ?int $id = null; + + #[RedisOm\Property(index: true)] + public string $name = ''; + + #[RedisOm\Property(index: true)] + public int $age = 0; + + #[RedisOm\Property(index: true)] + public float $price = 0.0; + + #[RedisOm\Property] + public array $tags = []; +} From 83d376f3f946a5df7243b1100a3657f2ba7982f8 Mon Sep 17 00:00:00 2001 From: clementtalleu Date: Fri, 24 Apr 2026 13:50:02 +0200 Subject: [PATCH 2/3] fix order by --- src/Command/GenerateSchema.php | 4 ++ .../Repository/AbstractObjectRepository.php | 69 +++++++++++++++++-- tests/Fixtures/AbstractDummy.php | 2 +- .../HashModel/HashRepositoryTest.php | 20 ++++++ 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/Command/GenerateSchema.php b/src/Command/GenerateSchema.php index 3b84169..ccb468d 100644 --- a/src/Command/GenerateSchema.php +++ b/src/Command/GenerateSchema.php @@ -136,6 +136,10 @@ public static function generateSchema(string $dir, ?RedisClientInterface $redisC $propertiesToIndex[] = new PropertyToIndex($propertyName, $propertyName, Property::INDEX_TAG); $propertiesToIndex[] = new PropertyToIndex($propertyName, $propertyName . '_numeric', Property::INDEX_NUMERIC); } else { + // JSON: scalar values are stored as strings by ScalarConverter, which makes + // automatic NUMERIC indexing unreliable (RediSearch rejects string values at + // a NUMERIC path). Users who need numeric sort/range on JSON must declare it + // explicitly via #[Property(index: [... => 'NUMERIC'])] on numeric data. $propertiesToIndex[] = new PropertyToIndex('$.' . $propertyName, $propertyName, Property::INDEX_TAG); } } elseif (class_exists($propertyType)) { diff --git a/src/Om/Repository/AbstractObjectRepository.php b/src/Om/Repository/AbstractObjectRepository.php index 96e6cf2..0980357 100644 --- a/src/Om/Repository/AbstractObjectRepository.php +++ b/src/Om/Repository/AbstractObjectRepository.php @@ -49,7 +49,7 @@ public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = nu $data = $this->redisClient->search( $this->prefix, $criteria, - $orderBy ?? [], + $this->rewriteOrderBy($orderBy), $this->format, $limit, offset: $offset, @@ -103,7 +103,7 @@ private function findByPattern(array $criteria, string $pattern, ?array $orderBy $data = $this->redisClient->search( prefixKey: $this->prefix, search: $transformedCriteria, - orderBy: $orderBy ?? [], + orderBy: $this->rewriteOrderBy($orderBy), format: $this->format, numberOfResults: $limit, offset: $offset, @@ -183,7 +183,7 @@ public function paginate(array $criteria = [], int $page = 1, int $itemsPerPage $data = $this->redisClient->search( $this->prefix, $criteria, - $orderBy ?? [], + $this->rewriteOrderBy($orderBy), $this->format, $itemsPerPage, offset: $offset, @@ -231,7 +231,7 @@ public function findOneBy(array $criteria, ?array $orderBy = null): ?object $this->convertObjects($criteria); $this->convertDates($criteria); $this->convertSpecial($criteria); - $data = $this->redisClient->search($this->prefix, $criteria, $orderBy ?? [], $this->format, 1, rangeFilters: $rangeFilters); + $data = $this->redisClient->search($this->prefix, $criteria, $this->rewriteOrderBy($orderBy), $this->format, 1, rangeFilters: $rangeFilters); if ($data === []) { return null; @@ -253,7 +253,7 @@ public function findOneByLike(array $criteria, ?array $orderBy = null): ?object unset($criteria[$property]); } - $data = $this->redisClient->search(prefixKey: $this->prefix, search: $criteria, orderBy: $orderBy ?? [], format: $this->format, numberOfResults: 1, searchType: Property::INDEX_TEXT); + $data = $this->redisClient->search(prefixKey: $this->prefix, search: $criteria, orderBy: $this->rewriteOrderBy($orderBy), format: $this->format, numberOfResults: 1, searchType: Property::INDEX_TEXT); if ($data === []) { return null; @@ -486,4 +486,63 @@ protected function convertDates(array &$criteria): void } } } + + /** + * Rewrite sort keys so int/float properties sort numerically instead of lexicographically. + * + * RediSearch TAG fields sort as strings ("10.2" < "100.0" < "9.5"), so sorting floats + * or ints via the default alias yields wrong order. For these types the schema exposes + * a parallel NUMERIC alias `{property}_numeric` (SORTABLE); this method swaps the key + * so SORTBY targets that alias instead. + * + * @param array|null $orderBy + * @return array + */ + protected function rewriteOrderBy(?array $orderBy): array + { + if (empty($orderBy) || $this->className === null) { + return $orderBy ?? []; + } + + $rewritten = []; + foreach ($orderBy as $property => $direction) { + $rewritten[$this->resolveSortField((string) $property)] = $direction; + } + + return $rewritten; + } + + private function resolveSortField(string $property): string + { + // Auto-rewriting to the NUMERIC alias is only safe for HASH: Redis parses string values + // at indexing time so a NUMERIC index on a string HASH field just works. In JSON format + // ScalarConverter stores scalars as strings, and RediSearch rejects string values at a + // NUMERIC JSONPath โ€” so users must opt in via #[Property(index: [... => 'NUMERIC'])]. + if ($this->format !== RedisFormat::HASH->value) { + return $property; + } + + if (!property_exists($this->className, $property)) { + return $property; + } + + $reflectionType = (new \ReflectionProperty($this->className, $property))->getType(); + if (!$reflectionType instanceof \ReflectionNamedType) { + return $property; + } + + $typeName = $reflectionType->getName(); + if ($typeName === 'int' || $typeName === 'float') { + return $property . '_numeric'; + } + + if (class_exists($typeName) && is_subclass_of($typeName, \BackedEnum::class)) { + $backingType = (new \ReflectionEnum($typeName))->getBackingType()?->getName(); + if ($backingType === 'int') { + return $property . '_numeric'; + } + } + + return $property; + } } diff --git a/tests/Fixtures/AbstractDummy.php b/tests/Fixtures/AbstractDummy.php index 8adaa1c..60422ff 100644 --- a/tests/Fixtures/AbstractDummy.php +++ b/tests/Fixtures/AbstractDummy.php @@ -24,7 +24,7 @@ abstract class AbstractDummy #[RedisOm\Property(index: true)] public ?int $age = null; - #[RedisOm\Property] + #[RedisOm\Property(index: true)] public ?float $price = null; #[RedisOm\Property(index: true)] diff --git a/tests/Functionnal/Om/Repository/HashModel/HashRepositoryTest.php b/tests/Functionnal/Om/Repository/HashModel/HashRepositoryTest.php index 06dee12..3e57e19 100644 --- a/tests/Functionnal/Om/Repository/HashModel/HashRepositoryTest.php +++ b/tests/Functionnal/Om/Repository/HashModel/HashRepositoryTest.php @@ -93,6 +93,26 @@ public function testFindByOrder() } } + public function testFindByOrderByFloatSortsNumerically() + { + static::emptyRedis(); + static::generateIndex(); + + $om = new RedisObjectManager(RedisAbstractTestCase::createRedisClient()); + $om->persist(DummyHash::create(id: 10, age: 20, price: 9.5, name: 'Alice')); + $om->persist(DummyHash::create(id: 11, age: 21, price: 14.5, name: 'Alice')); + $om->persist(DummyHash::create(id: 12, age: 22, price: 100.0, name: 'Alice')); + $om->flush(); + + $repository = $this->objectManager->getRepository(DummyHash::class); + + $asc = $repository->findBy(['name' => 'Alice'], ['price' => 'ASC']); + $this->assertSame([9.5, 14.5, 100.0], array_map(fn ($d) => $d->price, $asc)); + + $desc = $repository->findBy(['name' => 'Alice'], ['price' => 'DESC']); + $this->assertSame([100.0, 14.5, 9.5], array_map(fn ($d) => $d->price, $desc)); + } + public function testFindByMultiCriterias() { static::emptyRedis(); From d7fdb7ae26df71bc66dd8ab9146f1123bfda8945 Mon Sep 17 00:00:00 2001 From: clementtalleu Date: Fri, 24 Apr 2026 15:42:29 +0200 Subject: [PATCH 3/3] fix predisClient --- src/Client/PredisClient.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Client/PredisClient.php b/src/Client/PredisClient.php index 50d2185..05e7151 100644 --- a/src/Client/PredisClient.php +++ b/src/Client/PredisClient.php @@ -5,6 +5,7 @@ namespace Talleu\RedisOm\Client; use Predis\Client as Predis; +use Predis\Command\RawCommand; use Predis\Connection\StreamConnection; use Talleu\RedisOm\Client\Helper\Converter; use Talleu\RedisOm\Command\PropertyToIndex; @@ -365,7 +366,9 @@ public function jsonGetMultiple(array $keys): array { $results = $this->redis->pipeline(function ($pipeline) use ($keys) { foreach ($keys as $key) { - $pipeline->executeRaw([RedisCommands::JSON_GET->value, Converter::prefix($key)]); + // Predis pipelines have no executeRaw(); queue a RawCommand via executeCommand() + // so we can invoke JSON.GET (a non-core command) without the factory resolving it. + $pipeline->executeCommand(new RawCommand(RedisCommands::JSON_GET->value, [Converter::prefix($key)])); } });