Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions redis.c
Original file line number Diff line number Diff line change
Expand Up @@ -1763,6 +1763,7 @@ REDIS_METHOD(hgetdel, redis_hgetdel_cmd, redis_mbulk_reply_assoc);
REDIS_METHOD(hgetex, redis_hgetex_cmd, redis_mbulk_reply_assoc);
REDIS_METHOD(hsetex, redis_hsetex_cmd, redis_long_response);
REDIS_METHOD(incr, redis_incr_cmd, redis_long_response);
REDIS_METHOD(incrEx, redis_increx_cmd, redis_read_variant_reply);
REDIS_METHOD(info, redis_info_cmd, redis_info_response);
REDIS_METHOD(lInsert, redis_linsert_cmd, redis_long_response);
REDIS_METHOD(lPos, redis_lpos_cmd, redis_lpos_response);
Expand Down
25 changes: 25 additions & 0 deletions redis.stub.php
Original file line number Diff line number Diff line change
Expand Up @@ -2704,6 +2704,31 @@ public function incrBy(string $key, int $value): Redis|int|false;
*/
public function incrByFloat(string $key, float $value): Redis|float|false;

/**
* Increments the numeric value of a key by a number and sets its expiration time.
*
* This is an atomic operation combining incrementing with optional upper/lower bounds,
* saturation, and expiration control. Returns an array with the new value and the
* actual increment applied.
*
* @param string $key The key to increment.
* @param int|float $increment The amount to increment (defaults to 1, uses BYINT for int, BYFLOAT for float).
* @param null|array $options An optional associative array of options: 'EX'|'PX'|'EXAT'|'PXAT' => int,
* 'PERSIST', 'SATURATE', 'ENX' => bool, 'LBOUND'|'UBOUND' => int|float.
*
* @return Redis|array|false Returns an array of [new_value, actual_increment] or false on error.
*
* @see https://redis.io/docs/latest/commands/increx/
*
* @example
* $redis->del('mycounter');
* $redis->incrEx('mycounter');
* $redis->incrEx('mycounter', 5);
* $redis->incrEx('mycounter', 1, ['EX' => 100]);
* $redis->incrEx('mycounter', 1, ['UBOUND' => 100, 'SATURATE', 'EX' => 60, 'ENX']);
*/
public function incrEx(string $key, int|float $increment = 1, ?array $options = null): Redis|array|false;

/**
* Retrieve information about the connected redis-server. If no arguments are passed to
* this function, redis will return every info field. Alternatively you may pass a specific
Expand Down
8 changes: 8 additions & 0 deletions redis_arginfo.h
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,12 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Redis_incrByFloat, 0,
ZEND_ARG_TYPE_INFO(0, value, IS_DOUBLE, 0)
ZEND_END_ARG_INFO()

ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Redis_incrEx, 0, 1, Redis, MAY_BE_ARRAY|MAY_BE_FALSE)
ZEND_ARG_TYPE_INFO(0, key, IS_STRING, 0)
ZEND_ARG_TYPE_MASK(0, increment, MAY_BE_LONG|MAY_BE_DOUBLE, "1")
ZEND_ARG_TYPE_MASK(0, options, MAY_BE_ARRAY|MAY_BE_NULL, "null")
ZEND_END_ARG_INFO()

ZEND_BEGIN_ARG_WITH_RETURN_OBJ_TYPE_MASK_EX(arginfo_class_Redis_info, 0, 0, Redis, MAY_BE_ARRAY|MAY_BE_FALSE)
ZEND_ARG_VARIADIC_TYPE_INFO(0, sections, IS_STRING, 0)
ZEND_END_ARG_INFO()
Expand Down Expand Up @@ -1478,6 +1484,7 @@ ZEND_METHOD(Redis, expirememberat);
ZEND_METHOD(Redis, incr);
ZEND_METHOD(Redis, incrBy);
ZEND_METHOD(Redis, incrByFloat);
ZEND_METHOD(Redis, incrEx);
ZEND_METHOD(Redis, info);
ZEND_METHOD(Redis, isConnected);
ZEND_METHOD(Redis, keys);
Expand Down Expand Up @@ -1779,6 +1786,7 @@ static const zend_function_entry class_Redis_methods[] = {
ZEND_ME(Redis, incr, arginfo_class_Redis_incr, ZEND_ACC_PUBLIC)
ZEND_ME(Redis, incrBy, arginfo_class_Redis_incrBy, ZEND_ACC_PUBLIC)
ZEND_ME(Redis, incrByFloat, arginfo_class_Redis_incrByFloat, ZEND_ACC_PUBLIC)
ZEND_ME(Redis, incrEx, arginfo_class_Redis_incrEx, ZEND_ACC_PUBLIC)
ZEND_ME(Redis, info, arginfo_class_Redis_info, ZEND_ACC_PUBLIC)
ZEND_ME(Redis, isConnected, arginfo_class_Redis_isConnected, ZEND_ACC_PUBLIC)
ZEND_ME(Redis, keys, arginfo_class_Redis_keys, ZEND_ACC_PUBLIC)
Expand Down
154 changes: 154 additions & 0 deletions redis_commands.c
Original file line number Diff line number Diff line change
Expand Up @@ -2582,6 +2582,160 @@ RedisCmd *redis_decr_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock)
TYPE_DECR, redis_sock);
}

/* INCREX - atomic increment with optional bounds, saturation, and expiration */
RedisCmd *redis_increx_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock)
{
zval *z_increment = NULL, *z_opts = NULL;
zend_bool byfloat = 0, saturate = 0, enx = 0, persist = 0;
zend_long expire = -1;
char *exp_type = NULL;
zend_bool has_lbound = 0, has_ubound = 0;
zend_long lbound_l = 0, ubound_l = 0;
double lbound_d = 0, ubound_d = 0;
zend_string *key, *zkey_opt;
zval *z_ele;
RedisCmd *cmd;

ZEND_PARSE_PARAMETERS_START(1, 3)
Z_PARAM_STR(key)
Z_PARAM_OPTIONAL
Z_PARAM_ZVAL(z_increment)
Z_PARAM_OPTIONAL
Z_PARAM_ZVAL_OR_NULL(z_opts)
ZEND_PARSE_PARAMETERS_END_EX(return NULL);

/* Determine increment type */
if (z_increment != NULL && Z_TYPE_P(z_increment) == IS_DOUBLE) {
byfloat = 1;
}

/* Parse options */
if (z_opts != NULL && Z_TYPE_P(z_opts) == IS_ARRAY) {
ZEND_HASH_FOREACH_STR_KEY_VAL(Z_ARRVAL_P(z_opts), zkey_opt, z_ele) {
if (zkey_opt != NULL) {
ZVAL_DEREF(z_ele);
if (zend_string_equals_literal_ci(zkey_opt, "EX") ||
zend_string_equals_literal_ci(zkey_opt, "PX") ||
zend_string_equals_literal_ci(zkey_opt, "EXAT") ||
zend_string_equals_literal_ci(zkey_opt, "PXAT")
) {
exp_type = ZSTR_VAL(zkey_opt);
expire = zval_get_long(z_ele);
persist = 0;
} else if (zend_string_equals_literal_ci(zkey_opt, "PERSIST")) {
persist = zend_is_true(z_ele);
exp_type = NULL;
} else if (zend_string_equals_literal_ci(zkey_opt, "SATURATE")) {
saturate = zend_is_true(z_ele);
} else if (zend_string_equals_literal_ci(zkey_opt, "ENX")) {
enx = zend_is_true(z_ele);
} else if (zend_string_equals_literal_ci(zkey_opt, "LBOUND")) {
has_lbound = 1;
if (Z_TYPE_P(z_ele) == IS_DOUBLE) {
lbound_d = Z_DVAL_P(z_ele);
lbound_l = (zend_long)lbound_d;
} else {
lbound_l = zval_get_long(z_ele);
lbound_d = (double)lbound_l;
}
} else if (zend_string_equals_literal_ci(zkey_opt, "UBOUND")) {
has_ubound = 1;
if (Z_TYPE_P(z_ele) == IS_DOUBLE) {
ubound_d = Z_DVAL_P(z_ele);
ubound_l = (zend_long)ubound_d;
} else {
ubound_l = zval_get_long(z_ele);
ubound_d = (double)ubound_l;
}
}
} else {
ZVAL_DEREF(z_ele);
if (Z_TYPE_P(z_ele) == IS_STRING) {
if (zend_string_equals_literal_ci(Z_STR_P(z_ele), "PERSIST")) {
persist = 1;
exp_type = NULL;
} else if (zend_string_equals_literal_ci(Z_STR_P(z_ele), "SATURATE")) {
saturate = 1;
} else if (zend_string_equals_literal_ci(Z_STR_P(z_ele), "ENX")) {
enx = 1;
}
}
}
} ZEND_HASH_FOREACH_END();
}

/* Validate */
if (exp_type != NULL && expire < 1) {
php_error_docref(NULL, E_WARNING, "EXPIRE can't be < 1");
return NULL;
}

if (enx && exp_type == NULL) {
php_error_docref(NULL, E_WARNING,
"ENX requires one of EX, PX, EXAT, or PXAT");
return NULL;
}

/* Build INCREX command */
cmd = redis_cmd_create_literal(redis_sock, "INCREX");
redis_cmd_cat_key_zstr(cmd, key);

/* BYFLOAT / BYINT */
if (z_increment != NULL) {
if (byfloat) {
redis_cmd_cat_literal(cmd, "BYFLOAT");
if (Z_TYPE_P(z_increment) == IS_LONG) {
redis_cmd_cat_double(cmd, (double)Z_LVAL_P(z_increment));
} else {
redis_cmd_cat_double(cmd, Z_DVAL_P(z_increment));
}
} else {
redis_cmd_cat_literal(cmd, "BYINT");
redis_cmd_cat_long(cmd, zval_get_long(z_increment));
}
}

/* LBOUND */
if (has_lbound) {
redis_cmd_cat_literal(cmd, "LBOUND");
if (byfloat) {
redis_cmd_cat_double(cmd, lbound_d);
} else {
redis_cmd_cat_long(cmd, lbound_l);
}
}

/* UBOUND */
if (has_ubound) {
redis_cmd_cat_literal(cmd, "UBOUND");
if (byfloat) {
redis_cmd_cat_double(cmd, ubound_d);
} else {
redis_cmd_cat_long(cmd, ubound_l);
}
}

/* SATURATE */
if (saturate) {
redis_cmd_cat_literal(cmd, "SATURATE");
}

/* Expiration */
if (exp_type != NULL) {
redis_cmd_cat_str(cmd, exp_type, strlen(exp_type));
redis_cmd_cat_long(cmd, expire);
} else if (persist) {
redis_cmd_cat_literal(cmd, "PERSIST");
}

/* ENX */
if (enx) {
redis_cmd_cat_literal(cmd, "ENX");
}

return cmd;
}

typedef enum xdelExMode {
REDIS_XDELEX_NONE,
REDIS_XDELEX_KEEPREF,
Expand Down
1 change: 1 addition & 0 deletions redis_commands.h
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ RedisCmd *redis_set_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock);
RedisCmd *redis_getex_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock);
RedisCmd *redis_brpoplpush_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock);
RedisCmd *redis_incr_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock);
RedisCmd *redis_increx_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock);
RedisCmd *redis_decr_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock);
RedisCmd *redis_delex_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock);
RedisCmd *redis_hincrby_cmd(INTERNAL_FUNCTION_PARAMETERS, RedisSock *redis_sock);
Expand Down
102 changes: 102 additions & 0 deletions tests/RedisTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,108 @@ public function testIncrByFloat() {
$this->redis->del('someprefix:key');
}

public function testIncrEx() {
if (version_compare($this->version, '8.8.0') < 0)
$this->markTestSkipped('INCREX requires Redis >= 8.8.0');

$this->redis->del('increx_key');

/* Basic increment from non-existing key (starts at 0) */
[$newValue, $applied] = $this->redis->incrEx('increx_key');
$this->assertEquals(1, $newValue);
$this->assertEquals(1, $applied);

/* Increment again */
[$newValue, $applied] = $this->redis->incrEx('increx_key');
$this->assertEquals(2, $newValue);
$this->assertEquals(1, $applied);

/* BYINT with explicit value */
[$newValue, $applied] = $this->redis->incrEx('increx_key', 5);
$this->assertEquals(7, $newValue);
$this->assertEquals(5, $applied);

/* Negative increment (decrement) */
[$newValue, $applied] = $this->redis->incrEx('increx_key', -3);
$this->assertEquals(4, $newValue);
$this->assertEquals(-3, $applied);

/* BYFLOAT increment */
[$newValue, $applied] = $this->redis->incrEx('increx_key', 1.5);
$this->assertEquals('5.5', $newValue);
$this->assertEquals('1.5', $applied);

/* BYINT with UBOUND -- within bounds */
$this->redis->set('increx_key', 10);
[$newValue, $applied] = $this->redis->incrEx('increx_key', 5, ['UBOUND' => 100]);
$this->assertEquals(15, $newValue);
$this->assertEquals(5, $applied);

/* BYINT with UBOUND -- out of bounds (no SATURATE) */
[$newValue, $applied] = $this->redis->incrEx('increx_key', 5, ['UBOUND' => 17]);
$this->assertEquals(15, $newValue); /* value unchanged */
$this->assertEquals(0, $applied); /* no change applied */

/* BYINT with UBOUND + SATURATE */
[$newValue, $applied] = $this->redis->incrEx('increx_key', 5, ['UBOUND' => 17, 'SATURATE']);
$this->assertEquals(17, $newValue);
$this->assertEquals(2, $applied); /* saturated delta */

/* LBOUND test */
$this->redis->set('increx_key', 5);
[$newValue, $applied] = $this->redis->incrEx('increx_key', -10, ['LBOUND' => 0]);
$this->assertEquals(5, $newValue); /* unchanged */
$this->assertEquals(0, $applied);

/* LBOUND + SATURATE */
[$newValue, $applied] = $this->redis->incrEx('increx_key', -10, ['LBOUND' => 0, 'SATURATE']);
$this->assertEquals(0, $newValue);
$this->assertEquals(-5, $applied); /* saturated delta */

/* EX option */
$this->redis->del('increx_key');
[$newValue, $applied] = $this->redis->incrEx('increx_key', 1, ['EX' => 100]);
$this->assertEquals(1, $newValue);
$ttl = $this->redis->ttl('increx_key');
$this->assertGT(0, $ttl);
$this->assertLTE(100, $ttl);

/* ENX option */
$this->redis->del('increx_key');
[$newValue, $applied] = $this->redis->incrEx('increx_key', 1, ['EX' => 100, 'ENX']);
$this->assertEquals(1, $newValue);
$ttl = $this->redis->ttl('increx_key');
$this->assertGT(0, $ttl);

/* PERSIST option */
$this->redis->set('increx_key', 10);
$this->redis->expire('increx_key', 100);
[$newValue, $applied] = $this->redis->incrEx('increx_key', 1, ['PERSIST']);
$this->assertEquals(11, $newValue);
$this->assertEquals(-1, $this->redis->ttl('increx_key'));

/* PERSIST as positional boolean flag */
$this->redis->set('increx_key', 5);
$this->redis->expire('increx_key', 100);
[$newValue, $applied] = $this->redis->incrEx('increx_key', 1, ['PERSIST' => true]);
$this->assertEquals(6, $newValue);
$this->assertEquals(-1, $this->redis->ttl('increx_key'));

/* SATURATE as positional boolean flag */
$this->redis->set('increx_key', 95);
[$newValue, $applied] = $this->redis->incrEx('increx_key', 10, ['UBOUND' => 100, 'SATURATE']);
$this->assertEquals(100, $newValue);
$this->assertEquals(5, $applied);

/* Default increment with options */
$this->redis->del('increx_key');
[$newValue, $applied] = $this->redis->incrEx('increx_key', 1, ['EX' => 60]);
$this->assertEquals(1, $newValue);
$this->assertGT(0, $this->redis->ttl('increx_key'));

$this->redis->del('increx_key');
}

public function testDecr() {
$this->redis->set('key', 5);

Expand Down
Loading