diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 41b16fce..58c8b9e9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,7 +42,7 @@ jobs: run: | # Mapping of original redis versions to client test containers declare -A redis_clients_version_mapping=( - ["8.8"]="unstable-24805570909-debian" + ["8.8"]="unstable-25700596106-debian" ["8.6"]="8.6.1" ["8.4"]="8.4.0" ["8.2"]="8.2.2" diff --git a/CHANGELOG.md b/CHANGELOG.md index 767a01e9..46e60fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Added XNACK support (#1666) - Added support for multiple aggregators for TS range commands (#1670) - Added testing for subkey notification channels (#1671) +- Added support for INCREX command (#1674) ### Changed - Include command name in unsupported container command error messages (#1653) diff --git a/src/ClientContextInterface.php b/src/ClientContextInterface.php index 95ba3758..cd1c2f2f 100644 --- a/src/ClientContextInterface.php +++ b/src/ClientContextInterface.php @@ -153,6 +153,7 @@ use Predis\Command\Redis\VADD; * @method $this incr($key) * @method $this incrby($key, $increment) * @method $this incrbyfloat($key, $increment) + * @method $this increx(string $key, int|float|string $value, ?int $lbound = null, ?int $ubound = null, ?string $overflow = null, ?string $expireType = null, $expireValue = null, bool $enx = false) * @method $this mget(array $keys) * @method $this mset(array $dictionary) * @method $this msetex(array $dictionary, ?string $existModifier = null, ?string $expireResolution = null, ?int $expireTTL = null) diff --git a/src/ClientInterface.php b/src/ClientInterface.php index b8751b5d..28b76dbd 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -163,6 +163,7 @@ use Predis\Response\Status; * @method int incr(string $key) * @method int incrby(string $key, int $increment) * @method string incrbyfloat(string $key, int|float $increment) + * @method array increx(string $key, int|float|string $value, ?int $lbound = null, ?int $ubound = null, ?string $overflow = null, ?string $expireType = null, $expireValue = null, bool $enx = false) * @method array mget(string[]|string $keyOrKeys, string ...$keys = null) * @method mixed mset(array $dictionary) * @method array msetex(array $dictionary, ?string $existModifier = null, ?string $expireResolution = null, ?int $expireTTL = null) diff --git a/src/Cluster/ClusterStrategy.php b/src/Cluster/ClusterStrategy.php index 7eba2dcc..134b00ee 100644 --- a/src/Cluster/ClusterStrategy.php +++ b/src/Cluster/ClusterStrategy.php @@ -68,6 +68,7 @@ abstract class ClusterStrategy implements StrategyInterface 'INCR' => $getKeyFromFirstArgument, 'INCRBY' => $getKeyFromFirstArgument, 'INCRBYFLOAT' => $getKeyFromFirstArgument, + 'INCREX' => $getKeyFromFirstArgument, 'SETBIT' => $getKeyFromFirstArgument, 'SETEX' => $getKeyFromFirstArgument, 'MSET' => [$this, 'getKeyFromInterleavedArguments'], diff --git a/src/Command/Redis/INCREX.php b/src/Command/Redis/INCREX.php new file mode 100644 index 00000000..2b625164 --- /dev/null +++ b/src/Command/Redis/INCREX.php @@ -0,0 +1,190 @@ +resolveByType($arguments[1]); + $processed[] = $byType; + $processed[] = $arguments[1]; + } + + if (isset($arguments[2]) && $arguments[2] !== null) { + $processed[] = 'LBOUND'; + $processed[] = $arguments[2]; + } + + if (isset($arguments[3]) && $arguments[3] !== null) { + $processed[] = 'UBOUND'; + $processed[] = $arguments[3]; + } + + $overflow = $arguments[4] ?? null; + if ($overflow !== null && $overflow !== '') { + $overflow = strtoupper($overflow); + + if (!in_array($overflow, self::$overflowEnum, true)) { + $allowed = implode(', ', self::$overflowEnum); + throw new UnexpectedValueException("Overflow policy accepts only: {$allowed} values"); + } + + $processed[] = 'OVERFLOW'; + $processed[] = $overflow; + } + + $expireType = $arguments[5] ?? null; + if ($expireType !== null && $expireType !== '') { + $expireType = strtoupper($expireType); + + if (!in_array($expireType, self::$expireEnum, true)) { + $allowed = implode(', ', self::$expireEnum); + throw new UnexpectedValueException("Expire modifier accepts only: {$allowed} values"); + } + + if ($expireType === self::EXPIRE_PERSIST) { + $processed[] = self::EXPIRE_PERSIST; + } else { + if (!array_key_exists(6, $arguments) || $arguments[6] === null) { + throw new UnexpectedValueException("{$expireType} requires a value"); + } + + $processed[] = $expireType; + $processed[] = $arguments[6]; + } + } + + if (!empty($arguments[7])) { + $processed[] = 'ENX'; + } + + parent::setArguments($processed); + } + + /** + * {@inheritdoc} + * + * Normalizes the response to native numeric types so RESP2 and RESP3 are + * consistent: RESP2 returns BYFLOAT results as bulk strings while RESP3 + * returns native doubles. After parsing, callers always see int|float. + */ + public function parseResponse($data) + { + if (!is_array($data)) { + return $data; + } + + return array_map(static function ($v) { + if (!is_string($v) || !is_numeric($v)) { + return $v; + } + + return strpbrk($v, '.eE') !== false ? (float) $v : (int) $v; + }, $data); + } + + /** + * @param int|float|string $value + * @return string Either BYINT or BYFLOAT + */ + private function resolveByType($value): string + { + if (is_int($value)) { + return self::BY_INT; + } + + if (is_float($value)) { + return self::BY_FLOAT; + } + + if (!is_string($value) || !is_numeric($value)) { + throw new UnexpectedValueException( + 'Increment value must be an int, float, or numeric string' + ); + } + + // Numeric string: pick BYFLOAT when it carries a decimal point or + // exponent, otherwise treat it as an integer. + if (strpbrk($value, '.eE') !== false) { + return self::BY_FLOAT; + } + + return self::BY_INT; + } + + public function prefixKeys($prefix) + { + $this->applyPrefixForFirstArgument($prefix); + } +} diff --git a/tests/Predis/Cluster/PredisStrategyTest.php b/tests/Predis/Cluster/PredisStrategyTest.php index e1e0ee95..58b5f930 100644 --- a/tests/Predis/Cluster/PredisStrategyTest.php +++ b/tests/Predis/Cluster/PredisStrategyTest.php @@ -387,6 +387,7 @@ class PredisStrategyTest extends PredisTestCase 'INCR' => 'keys-first', 'INCRBY' => 'keys-first', 'INCRBYFLOAT' => 'keys-first', + 'INCREX' => 'keys-first', 'SETBIT' => 'keys-first', 'SETEX' => 'keys-first', 'MSET' => 'keys-interleaved', diff --git a/tests/Predis/Cluster/RedisStrategyTest.php b/tests/Predis/Cluster/RedisStrategyTest.php index 9eaf9a45..a18e1696 100644 --- a/tests/Predis/Cluster/RedisStrategyTest.php +++ b/tests/Predis/Cluster/RedisStrategyTest.php @@ -410,6 +410,7 @@ class RedisStrategyTest extends PredisTestCase 'INCR' => 'keys-first', 'INCRBY' => 'keys-first', 'INCRBYFLOAT' => 'keys-first', + 'INCREX' => 'keys-first', 'SETBIT' => 'keys-first', 'SETEX' => 'keys-first', 'MSET' => 'keys-interleaved', diff --git a/tests/Predis/Command/Redis/INCREX_Test.php b/tests/Predis/Command/Redis/INCREX_Test.php new file mode 100644 index 00000000..9b04ee95 --- /dev/null +++ b/tests/Predis/Command/Redis/INCREX_Test.php @@ -0,0 +1,461 @@ +getCommand(); + $command->setArguments($actual); + + $this->assertSame($expected, $command->getArguments()); + } + + public function argumentsProvider(): array + { + return [ + 'int value BYINT' => [ + ['key', 5], + ['key', 'BYINT', 5], + ], + 'negative int BYINT' => [ + ['key', -3], + ['key', 'BYINT', -3], + ], + 'float value BYFLOAT' => [ + ['key', 1.5], + ['key', 'BYFLOAT', 1.5], + ], + 'numeric string without decimal BYINT' => [ + ['key', '5'], + ['key', 'BYINT', '5'], + ], + 'numeric string with decimal BYFLOAT' => [ + ['key', '1.5'], + ['key', 'BYFLOAT', '1.5'], + ], + 'numeric string with exponent BYFLOAT' => [ + ['key', '1e3'], + ['key', 'BYFLOAT', '1e3'], + ], + 'with LBOUND and UBOUND' => [ + ['key', 5, 0, 100], + ['key', 'BYINT', 5, 'LBOUND', 0, 'UBOUND', 100], + ], + 'with LBOUND only' => [ + ['key', 1, 0], + ['key', 'BYINT', 1, 'LBOUND', 0], + ], + 'with UBOUND only' => [ + ['key', 1, null, 100], + ['key', 'BYINT', 1, 'UBOUND', 100], + ], + 'with OVERFLOW SAT' => [ + ['key', 5, null, 100, 'SAT'], + ['key', 'BYINT', 5, 'UBOUND', 100, 'OVERFLOW', 'SAT'], + ], + 'with EX expiration' => [ + ['key', 1, null, null, null, 'EX', 60], + ['key', 'BYINT', 1, 'EX', 60], + ], + 'with PERSIST' => [ + ['key', 1, null, null, null, 'PERSIST'], + ['key', 'BYINT', 1, 'PERSIST'], + ], + 'with ENX flag' => [ + ['key', 1, null, null, null, 'EX', 60, true], + ['key', 'BYINT', 1, 'EX', 60, 'ENX'], + ], + 'all options with int' => [ + ['key', 5, 0, 100, 'REJECT', 'PX', 5000, true], + ['key', 'BYINT', 5, 'LBOUND', 0, 'UBOUND', 100, 'OVERFLOW', 'REJECT', 'PX', 5000, 'ENX'], + ], + 'all options with float' => [ + ['key', 1.5, 0, 100, 'REJECT', 'PX', 5000, true], + ['key', 'BYFLOAT', 1.5, 'LBOUND', 0, 'UBOUND', 100, 'OVERFLOW', 'REJECT', 'PX', 5000, 'ENX'], + ], + ]; + } + + /** + * @group disconnected + */ + public function testThrowsExceptionOnNonNumericStringValue(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('numeric string'); + + $command = $this->getCommand(); + $command->setArguments(['key', 'not-a-number']); + } + + /** + * @group disconnected + */ + public function testThrowsExceptionOnNullValue(): void + { + $this->expectException(UnexpectedValueException::class); + + $command = $this->getCommand(); + $command->setArguments(['key', null]); + } + + /** + * @group disconnected + */ + public function testThrowsExceptionOnInvalidValueType(): void + { + $this->expectException(UnexpectedValueException::class); + + $command = $this->getCommand(); + $command->setArguments(['key', new stdClass()]); + } + + /** + * @group disconnected + */ + public function testThrowsExceptionOnInvalidOverflow(): void + { + $this->expectException(UnexpectedValueException::class); + + $command = $this->getCommand(); + $command->setArguments(['key', 1, null, null, 'INVALID']); + } + + /** + * @group disconnected + */ + public function testThrowsExceptionOnInvalidExpireType(): void + { + $this->expectException(UnexpectedValueException::class); + + $command = $this->getCommand(); + $command->setArguments(['key', 1, null, null, null, 'INVALID', 60]); + } + + /** + * @group disconnected + */ + public function testThrowsExceptionWhenExpireMissingValue(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('EX requires a value'); + + $command = $this->getCommand(); + $command->setArguments(['key', 1, null, null, null, 'EX']); + } + + /** + * @group disconnected + */ + public function testParseResponse(): void + { + // BYINT response - ints stay ints + $this->assertSame([5, 1], $this->getCommand()->parseResponse([5, 1])); + + // RESP2 BYFLOAT response - bulk strings converted to native floats + $this->assertSame([5.5, 1.5], $this->getCommand()->parseResponse(['5.5', '1.5'])); + + // RESP3 BYFLOAT response - already native floats, untouched + $this->assertSame([5.5, 1.5], $this->getCommand()->parseResponse([5.5, 1.5])); + + // Scientific notation + $this->assertSame([1000.0, 500.0], $this->getCommand()->parseResponse(['1e3', '5e2'])); + + // Non-array response - passed through (e.g. null) + $this->assertNull($this->getCommand()->parseResponse(null)); + } + + /** + * @group disconnected + */ + public function testPrefixKeys(): void + { + /** @var PrefixableCommand $command */ + $command = $this->getCommand(); + $actualArguments = ['arg1', 5]; + $prefix = 'prefix:'; + $expectedArguments = ['prefix:arg1', 'BYINT', 5]; + + $command->setArguments($actualArguments); + $command->prefixKeys($prefix); + + $this->assertSame($expectedArguments, $command->getArguments()); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testIncrementsByIntegerValue(): void + { + $redis = $this->getClient(); + + $redis->set('cnt', 10); + + $this->assertSame([15, 5], $redis->increx('cnt', 5)); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testIncrementsByFloatValue(): void + { + $redis = $this->getClient(); + + $redis->set('cnt', '10'); + + $this->assertSame([11.5, 1.5], $redis->increx('cnt', 1.5)); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testIncrementsByNumericIntegerString(): void + { + $redis = $this->getClient(); + + $redis->set('cnt', 10); + + $this->assertSame([17, 7], $redis->increx('cnt', '7')); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testIncrementsByNumericFloatString(): void + { + $redis = $this->getClient(); + + $redis->set('cnt', '10'); + + $this->assertSame([12.5, 2.5], $redis->increx('cnt', '2.5')); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testRespectsLowerAndUpperBounds(): void + { + $redis = $this->getClient(); + + $redis->set('cnt', 5); + + $this->assertSame([8, 3], $redis->increx('cnt', 3, 0, 100)); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testOverflowSatSaturatesToBound(): void + { + $redis = $this->getClient(); + + $redis->set('cnt', 10); + + $this->assertSame([50, 40], $redis->increx('cnt', 1000, null, 50, 'SAT')); + $this->assertSame('50', $redis->get('cnt')); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testOverflowRejectLeavesValueUnchanged(): void + { + $redis = $this->getClient(); + + $redis->set('cnt', 10); + + $redis->increx('cnt', 1000, null, 50, 'REJECT'); + $this->assertSame('10', $redis->get('cnt')); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testOverflowFailRaisesError(): void + { + $this->expectException('Predis\Response\ServerException'); + + $redis = $this->getClient(); + + $redis->set('cnt', 10); + $redis->increx('cnt', 1000, null, 50, 'FAIL'); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testExpirationWithPx(): void + { + $redis = $this->getClient(); + + $redis->set('cnt', 10); + + $redis->increx('cnt', 1, null, null, null, 'PX', 60000); + $this->assertGreaterThan(0, $redis->pttl('cnt')); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testExpirationWithPxat(): void + { + $redis = $this->getClient(); + + $redis->set('cnt', 10); + + $future = (int) ((microtime(true) + 60) * 1000); + + $redis->increx('cnt', 1, null, null, null, 'PXAT', $future); + $this->assertGreaterThan(0, $redis->pttl('cnt')); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testPersistRemovesTtl(): void + { + $redis = $this->getClient(); + + $redis->set('cnt', 10); + $redis->expire('cnt', 60); + $this->assertGreaterThan(0, $redis->ttl('cnt')); + + $redis->increx('cnt', 1, null, null, null, 'PERSIST'); + $this->assertSame(-1, $redis->ttl('cnt')); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testEnxSetsExpirationOnlyWhenAbsent(): void + { + $redis = $this->getClient(); + + $redis->set('cnt', 10); + + $redis->increx('cnt', 1, null, null, null, 'EX', 60, true); + $firstTtl = $redis->ttl('cnt'); + $this->assertGreaterThan(0, $firstTtl); + + $redis->increx('cnt', 1, null, null, null, 'EX', 9999, true); + $secondTtl = $redis->ttl('cnt'); + $this->assertLessThanOrEqual($firstTtl, $secondTtl); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testIncrementsByIntegerValueResp3(): void + { + $redis = $this->getResp3Client(); + + $redis->set('cnt', 10); + + $this->assertSame([15, 5], $redis->increx('cnt', 5)); + } + + /** + * RESP2 returns BYFLOAT results as bulk strings while RESP3 returns native + * doubles. After parseResponse, callers should see the same native numeric + * types regardless of protocol. + * + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testResponseTypesAreConsistentAcrossResp2AndResp3(): void + { + $resp2 = $this->getClient(); + $resp2->set('cnt', '10'); + $resp2Float = $resp2->increx('cnt', 1.5); + $resp2->set('cnt', 10); + $resp2Int = $resp2->increx('cnt', 5); + + $resp3 = $this->getResp3Client(false); + $resp3->set('cnt', '10'); + $resp3Float = $resp3->increx('cnt', 1.5); + $resp3->set('cnt', 10); + $resp3Int = $resp3->increx('cnt', 5); + + // BYFLOAT: both protocols yield native floats after normalization + $this->assertSame($resp2Float, $resp3Float); + $this->assertIsFloat($resp2Float[0]); + $this->assertIsFloat($resp2Float[1]); + $this->assertIsFloat($resp3Float[0]); + $this->assertIsFloat($resp3Float[1]); + + // BYINT: both protocols yield native ints + $this->assertSame($resp2Int, $resp3Int); + $this->assertIsInt($resp2Int[0]); + $this->assertIsInt($resp2Int[1]); + $this->assertIsInt($resp3Int[0]); + $this->assertIsInt($resp3Int[1]); + } + + /** + * @group connected + * @requiresRedisVersion >= 8.8.0 + */ + public function testThrowsExceptionOnWrongType(): void + { + $this->expectException('Predis\Response\ServerException'); + $this->expectExceptionMessage('Operation against a key holding the wrong kind of value'); + + $redis = $this->getClient(); + + $redis->lpush('foo', ['bar']); + $redis->increx('foo', 1); + } +}