diff --git a/common.h b/common.h index 1cfa3ff931..c1ed664791 100644 --- a/common.h +++ b/common.h @@ -91,20 +91,21 @@ typedef enum _PUBSUB_TYPE { #define REDIS_SUBS_BUCKETS 3 /* options */ -#define REDIS_OPT_SERIALIZER 1 -#define REDIS_OPT_PREFIX 2 -#define REDIS_OPT_READ_TIMEOUT 3 -#define REDIS_OPT_SCAN 4 -#define REDIS_OPT_FAILOVER 5 -#define REDIS_OPT_TCP_KEEPALIVE 6 -#define REDIS_OPT_COMPRESSION 7 -#define REDIS_OPT_REPLY_LITERAL 8 -#define REDIS_OPT_COMPRESSION_LEVEL 9 -#define REDIS_OPT_NULL_MBULK_AS_NULL 10 -#define REDIS_OPT_MAX_RETRIES 11 -#define REDIS_OPT_BACKOFF_ALGORITHM 12 -#define REDIS_OPT_BACKOFF_BASE 13 -#define REDIS_OPT_BACKOFF_CAP 14 +#define REDIS_OPT_SERIALIZER 1 +#define REDIS_OPT_PREFIX 2 +#define REDIS_OPT_READ_TIMEOUT 3 +#define REDIS_OPT_SCAN 4 +#define REDIS_OPT_FAILOVER 5 +#define REDIS_OPT_TCP_KEEPALIVE 6 +#define REDIS_OPT_COMPRESSION 7 +#define REDIS_OPT_REPLY_LITERAL 8 +#define REDIS_OPT_COMPRESSION_LEVEL 9 +#define REDIS_OPT_NULL_MBULK_AS_NULL 10 +#define REDIS_OPT_MAX_RETRIES 11 +#define REDIS_OPT_BACKOFF_ALGORITHM 12 +#define REDIS_OPT_BACKOFF_BASE 13 +#define REDIS_OPT_BACKOFF_CAP 14 +#define REDIS_OPT_PACK_IGNORE_NUMBERS 15 /* cluster options */ #define REDIS_FAILOVER_NONE 0 @@ -300,6 +301,7 @@ typedef struct { zend_string *persistent_id; HashTable *subs[REDIS_SUBS_BUCKETS]; redis_serializer serializer; + zend_bool pack_ignore_numbers; int compression; int compression_level; long dbNumber; diff --git a/library.c b/library.c index a264868003..35883fff38 100644 --- a/library.c +++ b/library.c @@ -3831,12 +3831,38 @@ redis_uncompress(RedisSock *redis_sock, char **dst, size_t *dstlen, const char * return 0; } +static int serialize_generic_zval(char **dst, size_t *len, zval *zsrc) { + zend_string *zstr; + + zstr = zval_get_string_func(zsrc); + if (ZSTR_IS_INTERNED(zstr)) { + *dst = ZSTR_VAL(zstr); + *len = ZSTR_LEN(zstr); + return 0; + } + + *dst = estrndup(ZSTR_VAL(zstr), ZSTR_LEN(zstr)); + *len = ZSTR_LEN(zstr); + + zend_string_release(zstr); + + return 1; +} + + PHP_REDIS_API int redis_pack(RedisSock *redis_sock, zval *z, char **val, size_t *val_len) { size_t tmplen; int tmpfree; char *tmp; + /* Don't pack actual numbers if the user asked us not to */ + if (UNEXPECTED(redis_sock->pack_ignore_numbers && + (Z_TYPE_P(z) == IS_LONG || Z_TYPE_P(z) == IS_DOUBLE))) + { + return serialize_generic_zval(val, val_len, z); + } + /* First serialize */ tmpfree = redis_serialize(redis_sock, z, &tmp, &tmplen); @@ -3851,9 +3877,29 @@ redis_pack(RedisSock *redis_sock, zval *z, char **val, size_t *val_len) { PHP_REDIS_API int redis_unpack(RedisSock *redis_sock, const char *src, int srclen, zval *zdst) { + zend_long lval; + double dval; size_t len; char *buf; + if (UNEXPECTED((redis_sock->serializer != REDIS_SERIALIZER_NONE || + redis_sock->compression != REDIS_COMPRESSION_NONE) && + redis_sock->pack_ignore_numbers) && + srclen > 0 && srclen < 512) + { + switch (is_numeric_string(src, srclen, &lval, &dval, 0)) { + case IS_LONG: + ZVAL_LONG(zdst, lval); + return 1; + case IS_DOUBLE: + ZVAL_DOUBLE(zdst, dval); + return 1; + default: + /* Fallthrough */ + break; + } + } + /* Uncompress, then unserialize */ if (redis_uncompress(redis_sock, &buf, &len, src, srclen)) { if (!redis_unserialize(redis_sock, buf, len, zdst)) { @@ -3898,18 +3944,8 @@ redis_serialize(RedisSock *redis_sock, zval *z, char **val, size_t *val_len) *val_len = 5; break; - default: { /* copy */ - zend_string *zstr = zval_get_string_func(z); - if (ZSTR_IS_INTERNED(zstr)) { // do not reallocate interned strings - *val = ZSTR_VAL(zstr); - *val_len = ZSTR_LEN(zstr); - return 0; - } - *val = estrndup(ZSTR_VAL(zstr), ZSTR_LEN(zstr)); - *val_len = ZSTR_LEN(zstr); - zend_string_efree(zstr); - return 1; - } + default: + return serialize_generic_zval(val, val_len, z); } break; case REDIS_SERIALIZER_PHP: diff --git a/redis.stub.php b/redis.stub.php index 930ae1f1be..8d0b7658c7 100644 --- a/redis.stub.php +++ b/redis.stub.php @@ -151,6 +151,32 @@ class Redis { */ public const OPT_NULL_MULTIBULK_AS_NULL = UNKNOWN; + /** + * @var int + * @cvalue REDIS_OPT_PACK_IGNORE_NUMBERS + * + * When enabled, this option tells PhpRedis to ignore purely numeric values + * when packing and unpacking data. This does not include numeric strings. + * If you want numeric strings to be ignored, typecast them to an int or float. + * + * The primary purpose of this option is to make it more ergonomic when + * setting keys that will later be incremented or decremented. + * + * Note: This option incurs a small performance penalty when reading data + * because we have to see if the data is a string representation of an int + * or float. + * + * @example + * $redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_IGBINARY); + * $redis->setOption(Redis::OPT_PACK_IGNORE_NUMBERS, true); + * + * $redis->set('answer', 32); + * + * var_dump($redis->incrBy('answer', 10)); // int(42) + * var_dump($redis->get('answer')); // int(42) + */ + public const OPT_PACK_IGNORE_NUMBERS = UNKNOWN; + /** * * @var int diff --git a/redis_arginfo.h b/redis_arginfo.h index 6df6763afe..072e1fb715 100644 --- a/redis_arginfo.h +++ b/redis_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 1f8f22ab9cd1635066463b20ab12d295c11b4ac7 */ + * Stub hash: 3c4051fdd9f860523bcd72aba260b1af823d1d9c */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Redis___construct, 0, 0, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, options, IS_ARRAY, 1, "null") @@ -1817,6 +1817,12 @@ static zend_class_entry *register_class_Redis(void) zend_declare_class_constant_ex(class_entry, const_OPT_NULL_MULTIBULK_AS_NULL_name, &const_OPT_NULL_MULTIBULK_AS_NULL_value, ZEND_ACC_PUBLIC, NULL); zend_string_release(const_OPT_NULL_MULTIBULK_AS_NULL_name); + zval const_OPT_PACK_IGNORE_NUMBERS_value; + ZVAL_LONG(&const_OPT_PACK_IGNORE_NUMBERS_value, REDIS_OPT_PACK_IGNORE_NUMBERS); + zend_string *const_OPT_PACK_IGNORE_NUMBERS_name = zend_string_init_interned("OPT_PACK_IGNORE_NUMBERS", sizeof("OPT_PACK_IGNORE_NUMBERS") - 1, 1); + zend_declare_class_constant_ex(class_entry, const_OPT_PACK_IGNORE_NUMBERS_name, &const_OPT_PACK_IGNORE_NUMBERS_value, ZEND_ACC_PUBLIC, NULL); + zend_string_release(const_OPT_PACK_IGNORE_NUMBERS_name); + zval const_SERIALIZER_NONE_value; ZVAL_LONG(&const_SERIALIZER_NONE_value, REDIS_SERIALIZER_NONE); zend_string *const_SERIALIZER_NONE_name = zend_string_init_interned("SERIALIZER_NONE", sizeof("SERIALIZER_NONE") - 1, 1); diff --git a/redis_commands.c b/redis_commands.c index 0c2aaa1232..2d57007ce8 100644 --- a/redis_commands.c +++ b/redis_commands.c @@ -6147,6 +6147,8 @@ void redis_getoption_handler(INTERNAL_FUNCTION_PARAMETERS, RETURN_LONG(redis_sock->compression); case REDIS_OPT_COMPRESSION_LEVEL: RETURN_LONG(redis_sock->compression_level); + case REDIS_OPT_PACK_IGNORE_NUMBERS: + RETURN_BOOL(redis_sock->pack_ignore_numbers); case REDIS_OPT_PREFIX: if (redis_sock->prefix) { RETURN_STRINGL(ZSTR_VAL(redis_sock->prefix), ZSTR_LEN(redis_sock->prefix)); @@ -6235,6 +6237,9 @@ void redis_setoption_handler(INTERNAL_FUNCTION_PARAMETERS, RETURN_TRUE; } break; + case REDIS_OPT_PACK_IGNORE_NUMBERS: + redis_sock->pack_ignore_numbers = zval_is_true(val); + RETURN_TRUE; case REDIS_OPT_COMPRESSION_LEVEL: val_long = zval_get_long(val); redis_sock->compression_level = val_long; diff --git a/redis_legacy_arginfo.h b/redis_legacy_arginfo.h index 80f212b2b4..27f0c44970 100644 --- a/redis_legacy_arginfo.h +++ b/redis_legacy_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 1f8f22ab9cd1635066463b20ab12d295c11b4ac7 */ + * Stub hash: 3c4051fdd9f860523bcd72aba260b1af823d1d9c */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Redis___construct, 0, 0, 0) ZEND_ARG_INFO(0, options) @@ -1660,6 +1660,12 @@ static zend_class_entry *register_class_Redis(void) zend_declare_class_constant_ex(class_entry, const_OPT_NULL_MULTIBULK_AS_NULL_name, &const_OPT_NULL_MULTIBULK_AS_NULL_value, ZEND_ACC_PUBLIC, NULL); zend_string_release(const_OPT_NULL_MULTIBULK_AS_NULL_name); + zval const_OPT_PACK_IGNORE_NUMBERS_value; + ZVAL_LONG(&const_OPT_PACK_IGNORE_NUMBERS_value, REDIS_OPT_PACK_IGNORE_NUMBERS); + zend_string *const_OPT_PACK_IGNORE_NUMBERS_name = zend_string_init_interned("OPT_PACK_IGNORE_NUMBERS", sizeof("OPT_PACK_IGNORE_NUMBERS") - 1, 1); + zend_declare_class_constant_ex(class_entry, const_OPT_PACK_IGNORE_NUMBERS_name, &const_OPT_PACK_IGNORE_NUMBERS_value, ZEND_ACC_PUBLIC, NULL); + zend_string_release(const_OPT_PACK_IGNORE_NUMBERS_name); + zval const_SERIALIZER_NONE_value; ZVAL_LONG(&const_SERIALIZER_NONE_value, REDIS_SERIALIZER_NONE); zend_string *const_SERIALIZER_NONE_name = zend_string_init_interned("SERIALIZER_NONE", sizeof("SERIALIZER_NONE") - 1, 1); diff --git a/tests/RedisTest.php b/tests/RedisTest.php index c9b16c7b17..3b46622337 100644 --- a/tests/RedisTest.php +++ b/tests/RedisTest.php @@ -76,7 +76,7 @@ public function setUp() { $info = $this->redis->info(); $this->version = (isset($info['redis_version'])?$info['redis_version']:'0.0.0'); $this->is_keydb = $this->detectKeyDB($info); - $this->is_valkey = $this->detectValKey($info); + $this->is_valkey = $this->detectValKey($info); } protected function minVersionCheck($version) { @@ -4958,6 +4958,104 @@ public function testSerializerPHP() { $this->redis->setOption(Redis::OPT_PREFIX, ''); } + private function cartesianProduct(array $arrays) { + $result = [[]]; + + foreach ($arrays as $array) { + $append = []; + foreach ($result as $product) { + foreach ($array as $item) { + $newProduct = $product; + $newProduct[] = $item; + $append[] = $newProduct; + } + } + + $result = $append; + } + + return $result; + } + + public function testIgnoreNumbers() { + $combinations = $this->cartesianProduct([ + [false, true, false], + $this->getSerializers(), + $this->getCompressors(), + ]); + + foreach ($combinations as [$ignore, $serializer, $compression]) { + $this->redis->setOption(Redis::OPT_PACK_IGNORE_NUMBERS, $ignore); + $this->redis->setOption(Redis::OPT_SERIALIZER, $serializer); + $this->redis->setOption(Redis::OPT_COMPRESSION, $compression); + + $this->assertIsInt($this->redis->del('answer')); + $this->assertIsInt($this->redis->del('hash')); + + $transparent = $compression === Redis::COMPRESSION_NONE && + ($serializer === Redis::SERIALIZER_NONE || + $serializer === Redis::SERIALIZER_JSON); + + if ($transparent || $ignore) { + $expected_answer = 42; + $expected_pi = 3.14; + } else { + $expected_answer = false; + $expected_pi = false; + } + + $this->assertTrue($this->redis->set('answer', 32)); + $this->assertEquals($expected_answer, $this->redis->incr('answer', 10)); + + $this->assertTrue($this->redis->set('pi', 3.04)); + $this->assertEquals($expected_pi, $this->redis->incrByFloat('pi', 0.1)); + + $this->assertEquals(1, $this->redis->hset('hash', 'answer', 32)); + $this->assertEquals($expected_answer, $this->redis->hIncrBy('hash', 'answer', 10)); + + $this->assertEquals(1, $this->redis->hset('hash', 'pi', 3.04)); + $this->assertEquals($expected_pi, $this->redis->hIncrByFloat('hash', 'pi', 0.1)); + } + + $this->redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE); + $this->redis->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_NONE); + $this->redis->setOption(Redis::OPT_PACK_IGNORE_NUMBERS, false); + } + + function testIgnoreNumbersReturnTypes() { + $combinations = $this->cartesianProduct([ + [false, true], + array_filter($this->getSerializers(), function($s) { + return $s !== Redis::SERIALIZER_NONE; + }), + array_filter($this->getCompressors(), function($c) { + return $c !== Redis::COMPRESSION_NONE; + }), + ]); + + foreach ($combinations as [$ignore, $serializer, $compression]) { + $this->redis->setOption(Redis::OPT_PACK_IGNORE_NUMBERS, $ignore); + $this->redis->setOption(Redis::OPT_SERIALIZER, $serializer); + $this->redis->setOption(Redis::OPT_COMPRESSION, $compression); + + foreach ([42, 3.14] as $value) { + $this->assertTrue($this->redis->set('key', $value)); + + /* There's a known issue in the PHP JSON parser, which + can stringify numbers. Unclear the root cause */ + if ($serializer == Redis::SERIALIZER_JSON) { + $this->assertEqualsWeak($value, $this->redis->get('key')); + } else { + $this->assertEquals($value, $this->redis->get('key')); + } + } + } + + $this->redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE); + $this->redis->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_NONE); + $this->redis->setOption(Redis::OPT_PACK_IGNORE_NUMBERS, false); + } + public function testSerializerIGBinary() { if ( ! defined('Redis::SERIALIZER_IGBINARY')) $this->markTestSkipped('Redis::SERIALIZER_IGBINARY is not defined');