diff --git a/README.md b/README.md index e53852b..5fcbded 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,10 @@ written in Rust. commands (the `ephpm_kv_*` SAPI functions have shipped since ePHPm v0.1.0; `FLUSHDB`/`FLUSHALL` need `ephpm_kv_flush_all()`, added in v0.1.2; `SETNX` / `SET … NX` need `ephpm_kv_setnx()`; current release: - v0.10.2). The functions are + v0.10.2). The hash, multi-key and other commands listed under + [ePHPm v0.13+ commands](#ephpm-v013-commands) need ePHPm **v0.13.0+**; + each is feature-detected, so on an older runtime those commands keep + raising `CommandNotSupportedException` exactly as before. The functions are registered by ePHPm's embedded PHP. If you're running your code under PHP-FPM, Apache mod_php, or the stock PHP CLI, those functions don't exist and `SapiKvOps::__construct()` throws on instantiation. For @@ -236,7 +239,8 @@ keep them on a real Redis (or a different transport): that ships a Lua script — they run via `EVAL`, which this connection does not implement. (Laravel's *cache-based* limiter — `RateLimiter` / the `throttle` middleware — is fine: it only uses counter ops.) -- Any explicit list / hash / set / sorted-set / stream ops you've written. +- Any explicit list / set / sorted-set / stream ops you've written (and hash + ops on ePHPm older than v0.13.0). ### Sanity check from artisan @@ -356,20 +360,49 @@ If this round-trips successfully you've confirmed: | `DEL`, `UNLINK`, `EXISTS` | Multi-key, return count. | | `INCR`, `DECR`, `INCRBY`, `DECRBY` | Atomic counter ops via the SAPI's `ephpm_kv_incr_by`. | | `EXPIRE`, `PEXPIRE`, `TTL`, `PTTL` | TTL management. `PEXPIRE` rounds up. | -| `TYPE` | Returns `string` if the key exists, `none` if not. | +| `TYPE` | `string`, `hash` or `none` via `ephpm_kv_type` on ePHPm v0.13+; on older runtimes `string` if the key exists, `none` if not. | | `PING`, `ECHO` | Connection liveness, payload echo. | | `SELECT`, `AUTH`, `QUIT` | Tolerated as no-ops so framework handshakes don't break. | | `FLUSHDB`, `FLUSHALL` | Clear the entire effective store. ephpm's KV is a single keyspace, so both are equivalent. Needs ePHPm v0.1.2+ (`ephpm_kv_flush_all`); on v0.1.0/v0.1.1 this is a no-op returning `null`. | -Everything else — lists, sets, hashes, sorted sets, streams, scripting -(`EVAL`), pub/sub (`SUBSCRIBE`/`PUBLISH`), `MULTI`/`EXEC` — raises +Everything else — lists, sets, sorted sets, streams, scripting +(`EVAL`), pub/sub (`SUBSCRIBE`/`PUBLISH`), `MULTI`/`EXEC`, and the commands +below on an ePHPm older than v0.13.0 — raises `Ephpm\Predis\CommandNotSupportedException` with a clear "ephpm KV does not -implement \" message. ePHPm's KV store is intentionally a string + -counter store; if you need anything beyond that, point Predis at a real +implement \" message (listing the commands that *are* available on the +running ePHPm). ePHPm's KV store is intentionally a string + counter + hash +store; if you need anything beyond that, point Predis at a real Redis for those calls. In particular, **Lua-scripted rate limiters, pub/sub, and broadcasting do not work through this connection** — they need `EVAL`/`SUBSCRIBE`/`PUBLISH`. +### ePHPm v0.13+ commands + +These map onto the `ephpm_kv_*` functions added in ePHPm v0.13.0. Each one is +feature-detected with `function_exists()` (`KvConnection::EXTENDED_COMMANDS` +lists the functions each command needs); when the running ePHPm lacks them the +command raises `CommandNotSupportedException`, as it always did. + +| Command(s) | Behavior | +| ------------------------------------------------ | ----------------------------------------------------------------------- | +| `HSET`, `HMSET` | Any number of field/value pairs in one write. `HSET` returns the number of fields created, `HMSET` `OK`. A refused write raises `ServerException`. | +| `HGET`, `HMGET`, `HGETALL` | nil for a missing field/hash. `HGETALL` returns an associative array (Predis folds the flat reply). | +| `HDEL`, `HEXISTS`, `HLEN`, `HKEYS`, `HVALS` | As in Redis. | +| `HSETNX`, `HINCRBY` | `1`/`0`; new value (non-integer field raises `ServerException`). `HINCRBY` keeps the field's TTL. | +| `HEXPIRE key seconds FIELDS n f …` | Per-field reply: `1` set, `-2` no such field, `2` deleted (`seconds` = 0). The `NX`/`XX`/`GT`/`LT` conditions are **not supported** (they would need a non-atomic read-then-write) and raise `CommandNotSupportedException`; so do `HPEXPIRE`, `HEXPIREAT`, `HPEXPIREAT`. | +| `HTTL`, `HPTTL`, `HPERSIST` | Per-field replies as in Redis (`-2` no field, `-1` no TTL). | +| `MGET`, `MSET` | Multi-key read / write (no TTL). `MSET` refused under the memory limit raises `ServerException`; keys before the refused one may already be stored. | +| `GETDEL` | Read and delete. | +| `GETEX` | No option (plain read), `EX`, `PERSIST`, and `PX` when it is a whole number of seconds. Sub-second `PX`, `EXAT` and `PXAT` raise `CommandNotSupportedException`. | +| `PERSIST`, `APPEND`, `STRLEN` | As in Redis. | +| `SCAN cursor [MATCH p] [COUNT n]` | Reply `[cursor-as-string, keys]`; works with Predis' `Keyspace` iterator. `TYPE` filter unsupported. **In a cluster SCAN lists only this node's keyspace**: every hash and every string up to `[cluster.kv] small_key_threshold` is on every node, but larger string values only on their replica nodes. | +| `SET … IFEQ expected` | Compare-and-set (`ephpm_kv_set_if_eq`): `OK` if the current value equals `expected`, nil otherwise; never creates the key. `EX`/`PX` set a TTL, `KEEPTTL` keeps it, neither clears it. Not combinable with `NX`. | + +Hash notes: hash field values in a **cluster** are limited to +`[cluster.kv] small_key_threshold - 17` bytes (495 by default); a larger +value makes `HSET` raise `ServerException`. Whole-key `EXPIRE`/`TTL`/`DEL` work +on hash keys too. + --- ## SET modifier matrix @@ -379,9 +412,10 @@ need `EVAL`/`SUBSCRIBE`/`PUBLISH`. | `EX seconds` | supported | | `PX milliseconds` | supported (rounded up to seconds) | | `NX` | supported (atomic insert-if-absent via `ephpm_kv_setnx()`) | +| `IFEQ expected` | supported on ePHPm v0.13+ (compare-and-set via `ephpm_kv_set_if_eq()`) | | `XX` | unsupported | | `GET` | unsupported | -| `KEEPTTL` | unsupported | +| `KEEPTTL` | only together with `IFEQ` | | `EXAT`, `PXAT` | unsupported | ### OOM and SETNX @@ -445,7 +479,9 @@ assert($client->get('foo') === 'bar'); `InMemoryKvOps` is for tests only — values live in PHP arrays, there is no eviction policy, no memory limit, and TTL is best-effort lazy -expiry. Don't use it in production. +expiry. Don't use it in production. It implements the v0.13 commands too; +pass a list of operation names to emulate an older ePHPm — `new +InMemoryKvOps([])` behaves like a runtime without any of them. --- @@ -460,7 +496,8 @@ the second arg of `KvConnection` (tests). ### `CommandNotSupportedException: ephpm KV does not implement 'LPUSH'` You called a Redis command outside the supported subset (lists/sets/ -hashes/streams/scripts/pub-sub). Either change the call to use the +streams/scripts/pub-sub, or hashes and the other +[v0.13 commands](#ephpm-v013-commands) on an older ePHPm). Either change the call to use the string + counter ops your data model can be expressed with, or keep that workload on a real Redis (you can wire two clients in your app and route per-key-pattern). diff --git a/src/CommandNotSupportedException.php b/src/CommandNotSupportedException.php index 537de01..960b5e4 100644 --- a/src/CommandNotSupportedException.php +++ b/src/CommandNotSupportedException.php @@ -8,18 +8,24 @@ /** * Thrown when client code dispatches a Predis command that ePHPm's KV - * store does not implement (lists, hashes, sets, sorted sets, streams, - * scripting, pub/sub, MULTI/EXEC, …). Surfaces the unsupported command + * store does not implement (lists, sets, sorted sets, streams, scripting, + * pub/sub, MULTI/EXEC, … — and hashes / the other v0.13 commands when the + * running ePHPm predates them). Surfaces the unsupported command * name in the message so callers can switch their code over to the * supported subset or move that workload to a real Redis. */ final class CommandNotSupportedException extends PredisException { - public function __construct(string $commandId) + /** + * @param list|null $supported the commands the connection does + * execute (defaults to the base set, + * {@see KvConnection::SUPPORTED_COMMANDS}) + */ + public function __construct(string $commandId, ?array $supported = null) { parent::__construct( "ephpm KV does not implement the Redis command '{$commandId}'. " - . 'Supported commands: ' . \implode(', ', KvConnection::SUPPORTED_COMMANDS) . '.' + . 'Supported commands: ' . \implode(', ', $supported ?? KvConnection::SUPPORTED_COMMANDS) . '.' ); } } diff --git a/src/ExtendedKvOpsInterface.php b/src/ExtendedKvOpsInterface.php new file mode 100644 index 0000000..0e26d75 --- /dev/null +++ b/src/ExtendedKvOpsInterface.php @@ -0,0 +1,162 @@ + $fields field => value + * + * @return int|false number of fields created, false on failure + */ + public function hset(string $key, array $fields): int|false; + + /** `ephpm_kv_hsetnx`: true if set, false if the field existed (or failure). */ + public function hsetnx(string $key, string $field, string $value): bool; + + /** + * `ephpm_kv_hmget`. + * + * @param list $fields + * + * @return list|false aligned with `$fields`; false on failure + */ + public function hmget(string $key, array $fields): array|false; + + /** + * `ephpm_kv_hgetall`. + * + * @return array|false field => value; false on failure + */ + public function hgetall(string $key): array|false; + + /** + * `ephpm_kv_hdel`. + * + * @param list $fields + */ + public function hdel(string $key, array $fields): int; + + /** `ephpm_kv_hexists`. */ + public function hexists(string $key, string $field): bool; + + /** `ephpm_kv_hlen`: 0 for a missing hash. */ + public function hlen(string $key): int; + + /** + * `ephpm_kv_hkeys`. + * + * @return list|false + */ + public function hkeys(string $key): array|false; + + /** + * `ephpm_kv_hvals`. + * + * @return list|false + */ + public function hvals(string $key): array|false; + + /** `ephpm_kv_hincr_by`: new value; false for a non-integer field, overflow or failure. */ + public function hincrBy(string $key, string $field, int $delta): int|false; + + /** + * `ephpm_kv_hexpire` for one field: 1 if the field got the TTL, 0 if it + * does not exist, false if `$ttlSeconds` is out of range or on failure. + */ + public function hexpire(string $key, string $field, int $ttlSeconds): int|false; + + /** `ephpm_kv_httl`: seconds remaining, -1 no TTL, -2 no such field. */ + public function httl(string $key, string $field): int; + + /** `ephpm_kv_hpttl`: milliseconds remaining, -1 no TTL, -2 no such field. */ + public function hpttl(string $key, string $field): int; + + /** `ephpm_kv_hpersist`: true if a field TTL was removed. */ + public function hpersist(string $key, string $field): bool; + + /** + * `ephpm_kv_mget`. + * + * @param list $keys + * + * @return list|false aligned with `$keys`; false on failure + */ + public function mget(array $keys): array|false; + + /** + * `ephpm_kv_mset`: write every pair with the same TTL (0 = none). + * + * @param array $values key => value + */ + public function mset(array $values, int $ttlSeconds = 0): bool; + + /** `ephpm_kv_getdel`: the value (then deleted), or null if missing. */ + public function getdel(string $key): ?string; + + /** `ephpm_kv_getex`: read and set the TTL (> 0 seconds) or remove it (0). */ + public function getex(string $key, int $ttlSeconds): ?string; + + /** `ephpm_kv_persist`: true if a TTL was removed. */ + public function persist(string $key): bool; + + /** + * `ephpm_kv_set_if_eq`: replace the value only while it equals + * `$expected`; never creates the key. `$ttlSeconds` > 0 = new TTL, + * 0 = no TTL, -1 = keep the current TTL. + */ + public function setIfEq(string $key, string $expected, string $value, int $ttlSeconds = 0): bool; + + /** `ephpm_kv_append`: the new length, false on failure. */ + public function append(string $key, string $value): int|false; + + /** `ephpm_kv_strlen`: 0 if missing. */ + public function strlen(string $key): int; + + /** `ephpm_kv_type`: "string", "hash" or "none". */ + public function type(string $key): string; + + /** + * `ephpm_kv_scan`: one step of an incremental keyspace scan. + * + * @return array{0: int, 1: list}|false [next cursor (0 = done), keys] + */ + public function scan(int $cursor, ?string $match = null, int $count = 10): array|false; +} diff --git a/src/InMemoryKvOps.php b/src/InMemoryKvOps.php index 30c82cb..58f8eee 100644 --- a/src/InMemoryKvOps.php +++ b/src/InMemoryKvOps.php @@ -12,8 +12,15 @@ * TTL semantics match the SAPI: TTLs are stored as monotonic deadlines * in milliseconds; lazy expiry (a key is only "gone" when it's looked up * after its deadline). No background sweeper. + * + * It also models the ePHPm v0.13 operations ({@see ExtendedKvOpsInterface}): + * hashes live in their own namespace beside strings (as in ePHPm, a name can + * be both), with per-field TTLs. Pass a list of operation names to the + * constructor to emulate an older ePHPm that lacks some of them — `[]` + * behaves like a pre-v0.13 runtime. Whole-key TTLs (`expire`/`ttl`/`pttl`/ + * `persist`) apply to string keys only in this fake. */ -final class InMemoryKvOps implements KvOpsInterface +final class InMemoryKvOps implements ExtendedKvOpsInterface { /** @var array */ private array $values = []; @@ -21,6 +28,25 @@ final class InMemoryKvOps implements KvOpsInterface /** @var array deadline in milliseconds since epoch */ private array $deadlines = []; + /** @var array> */ + private array $hashes = []; + + /** @var array> field deadline in ms since epoch */ + private array $fieldDeadlines = []; + + /** @var list */ + private array $ops; + + /** + * @param list|null $ops the v0.13 operations to report as + * available (see {@see ExtendedKvOpsInterface::OPS}); + * null = all of them, [] = a pre-v0.13 runtime + */ + public function __construct(?array $ops = null) + { + $this->ops = $ops ?? self::OPS; + } + /** * Test hook: when true, every write (`set`/`setnx`) refuses like a store * that has hit `maxmemory` under `noeviction` — the SAPI returns `false` @@ -71,8 +97,11 @@ public function setnx(string $key, string $value, int $ttlSeconds = 0): bool public function del(string $key): int { + // Like ephpm_kv_del, deletes a hash of that name too. + $hadHash = $this->liveHash($key) !== []; + unset($this->hashes[$key], $this->fieldDeadlines[$key]); if ($this->liveValue($key) === null) { - return 0; + return $hadHash ? 1 : 0; } unset($this->values[$key], $this->deadlines[$key]); return 1; @@ -132,9 +161,304 @@ public function flush(): bool { $this->values = []; $this->deadlines = []; + $this->hashes = []; + $this->fieldDeadlines = []; + return true; + } + + // ── v0.13 operations ───────────────────────────────────────────────────── + + public function supports(string $op): bool + { + return \in_array($op, $this->ops, true); + } + + public function hget(string $key, string $field): ?string + { + return $this->liveHash($key)[$field] ?? null; + } + + public function hset(string $key, array $fields): int|false + { + if ($this->oom) { + return false; + } + $hash = $this->liveHash($key); + $created = 0; + foreach ($fields as $field => $value) { + if (!\array_key_exists($field, $hash)) { + $created++; + } + $hash[$field] = (string) $value; + // Writing a field clears its TTL. + unset($this->fieldDeadlines[$key][$field]); + } + $this->hashes[$key] = $hash; + return $created; + } + + public function hsetnx(string $key, string $field, string $value): bool + { + if ($this->oom || \array_key_exists($field, $this->liveHash($key))) { + return false; + } + return $this->hset($key, [$field => $value]) !== false; + } + + public function hmget(string $key, array $fields): array|false + { + $hash = $this->liveHash($key); + $out = []; + foreach ($fields as $field) { + $out[] = $hash[$field] ?? null; + } + return $out; + } + + public function hgetall(string $key): array|false + { + return $this->liveHash($key); + } + + public function hdel(string $key, array $fields): int + { + $hash = $this->liveHash($key); + $removed = 0; + foreach (\array_unique($fields) as $field) { + if (\array_key_exists($field, $hash)) { + unset($hash[$field], $this->fieldDeadlines[$key][$field]); + $removed++; + } + } + $this->storeHash($key, $hash); + return $removed; + } + + public function hexists(string $key, string $field): bool + { + return \array_key_exists($field, $this->liveHash($key)); + } + + public function hlen(string $key): int + { + return \count($this->liveHash($key)); + } + + public function hkeys(string $key): array|false + { + return \array_keys($this->liveHash($key)); + } + + public function hvals(string $key): array|false + { + return \array_values($this->liveHash($key)); + } + + public function hincrBy(string $key, string $field, int $delta): int|false + { + $current = $this->liveHash($key)[$field] ?? '0'; + if (\preg_match('/^-?\d+$/', $current) !== 1) { + return false; + } + $next = ((int) $current) + $delta; + // hincr_by keeps the field's TTL, so write around hset(). + $this->hashes[$key][$field] = (string) $next; + return $next; + } + + public function hexpire(string $key, string $field, int $ttlSeconds): int|false + { + if ($ttlSeconds <= 0) { + return false; + } + if (!\array_key_exists($field, $this->liveHash($key))) { + return 0; + } + $this->fieldDeadlines[$key][$field] = $this->nowMs() + ($ttlSeconds * 1000); + return 1; + } + + public function httl(string $key, string $field): int + { + $ms = $this->hpttl($key, $field); + return $ms < 0 ? $ms : (int) (($ms + 999) / 1000); + } + + public function hpttl(string $key, string $field): int + { + if (!\array_key_exists($field, $this->liveHash($key))) { + return -2; + } + if (!isset($this->fieldDeadlines[$key][$field])) { + return -1; + } + return \max(0, $this->fieldDeadlines[$key][$field] - $this->nowMs()); + } + + public function hpersist(string $key, string $field): bool + { + if ($this->hpttl($key, $field) < 0) { + return false; + } + unset($this->fieldDeadlines[$key][$field]); return true; } + public function mget(array $keys): array|false + { + $out = []; + foreach ($keys as $key) { + $out[] = $this->liveValue((string) $key); + } + return $out; + } + + public function mset(array $values, int $ttlSeconds = 0): bool + { + foreach ($values as $key => $value) { + if (!$this->set((string) $key, (string) $value, $ttlSeconds)) { + return false; + } + } + return true; + } + + public function getdel(string $key): ?string + { + $value = $this->liveValue($key); + if ($value !== null) { + unset($this->values[$key], $this->deadlines[$key]); + } + return $value; + } + + public function getex(string $key, int $ttlSeconds): ?string + { + if ($ttlSeconds < 0) { + return null; + } + $value = $this->liveValue($key); + if ($value === null) { + return null; + } + if ($ttlSeconds === 0) { + unset($this->deadlines[$key]); + } else { + $this->deadlines[$key] = $this->nowMs() + ($ttlSeconds * 1000); + } + return $value; + } + + public function persist(string $key): bool + { + if ($this->liveValue($key) === null || !isset($this->deadlines[$key])) { + return false; + } + unset($this->deadlines[$key]); + return true; + } + + public function setIfEq(string $key, string $expected, string $value, int $ttlSeconds = 0): bool + { + if ($this->oom || $this->liveValue($key) !== $expected) { + return false; + } + $this->values[$key] = $value; + if ($ttlSeconds > 0) { + $this->deadlines[$key] = $this->nowMs() + ($ttlSeconds * 1000); + } elseif ($ttlSeconds === 0) { + unset($this->deadlines[$key]); + } + return true; + } + + public function append(string $key, string $value): int|false + { + if ($this->oom) { + return false; + } + $next = ($this->liveValue($key) ?? '') . $value; + $this->values[$key] = $next; + return \strlen($next); + } + + public function strlen(string $key): int + { + return \strlen($this->liveValue($key) ?? ''); + } + + public function type(string $key): string + { + if ($this->liveValue($key) !== null) { + return 'string'; + } + return $this->liveHash($key) !== [] ? 'hash' : 'none'; + } + + /** + * The cursor is an offset into a sorted snapshot of every live name (a + * name that is both a string and a hash appears once per type), which is + * enough for a single-threaded test double. + */ + public function scan(int $cursor, ?string $match = null, int $count = 10): array|false + { + if ($cursor < 0 || $count < 1) { + return false; + } + $names = []; + foreach (\array_keys($this->values) as $key) { + if ($this->liveValue((string) $key) !== null) { + $names[] = (string) $key; + } + } + foreach (\array_keys($this->hashes) as $key) { + if ($this->liveHash((string) $key) !== []) { + $names[] = (string) $key; + } + } + \sort($names, \SORT_STRING); + + $page = \array_slice($names, $cursor, $count); + $next = $cursor + $count >= \count($names) ? 0 : $cursor + $count; + if ($match !== null) { + $page = \array_values(\array_filter( + $page, + static fn (string $name): bool => \fnmatch($match, $name), + )); + } + return [$next, $page]; + } + + /** + * A hash's live fields, dropping (lazily) any whose TTL has passed. + * + * @return array + */ + private function liveHash(string $key): array + { + $hash = $this->hashes[$key] ?? []; + $now = $this->nowMs(); + foreach ($this->fieldDeadlines[$key] ?? [] as $field => $deadline) { + if ($deadline <= $now) { + unset($hash[$field], $this->fieldDeadlines[$key][$field]); + } + } + $this->storeHash($key, $hash); + return $hash; + } + + /** + * @param array $hash + */ + private function storeHash(string $key, array $hash): void + { + if ($hash === []) { + unset($this->hashes[$key], $this->fieldDeadlines[$key]); + } else { + $this->hashes[$key] = $hash; + } + } + /** * Resolve a key's value, expiring it lazily if its deadline has passed. */ diff --git a/src/KvConnection.php b/src/KvConnection.php index 5190d96..94203e2 100644 --- a/src/KvConnection.php +++ b/src/KvConnection.php @@ -27,10 +27,12 @@ * * Only the subset of Redis that ephpm's KV store implements is wired * through (string GET/SET, counters, TTL, EXISTS, DEL, plus a couple - * connection-management no-ops). Everything else throws + * connection-management no-ops; on ePHPm v0.13+ also hashes, MGET/MSET, + * GETDEL/GETEX, PERSIST, APPEND/STRLEN, SCAN and SET … IFEQ — see + * {@see EXTENDED_COMMANDS}, each feature-detected). Everything else throws * {@see CommandNotSupportedException} with a clear "ephpm KV does not * implement " message — that surface is intentional, so callers - * who reach for hashes/lists/streams/scripts know to keep that workload + * who reach for lists/sets/streams/scripts know to keep that workload * on a real Redis. */ final class KvConnection implements NodeConnectionInterface @@ -67,6 +69,42 @@ final class KvConnection implements NodeConnectionInterface 'FLUSHALL', ]; + /** + * Commands wired to the `ephpm_kv_*` functions added in ePHPm v0.13.0, + * mapped to the backend operations ({@see ExtendedKvOpsInterface::OPS}) + * each one needs. A command is executed only when the backend supports + * every listed operation; otherwise it raises + * {@see CommandNotSupportedException} exactly as before v0.13. + * (`TYPE` is in {@see SUPPORTED_COMMANDS} and upgrades itself to + * `ephpm_kv_type` when available; `SET … IFEQ` needs `set_if_eq`.) + */ + public const EXTENDED_COMMANDS = [ + 'HSET' => ['hset'], + 'HMSET' => ['hset'], + 'HSETNX' => ['hsetnx'], + 'HGET' => ['hget'], + 'HMGET' => ['hmget'], + 'HGETALL' => ['hgetall'], + 'HDEL' => ['hdel'], + 'HEXISTS' => ['hexists'], + 'HLEN' => ['hlen'], + 'HKEYS' => ['hkeys'], + 'HVALS' => ['hvals'], + 'HINCRBY' => ['hincr_by'], + 'HEXPIRE' => ['hexpire', 'hdel'], + 'HTTL' => ['httl'], + 'HPTTL' => ['hpttl'], + 'HPERSIST' => ['hpersist', 'hpttl'], + 'MGET' => ['mget'], + 'MSET' => ['mset'], + 'GETDEL' => ['getdel'], + 'GETEX' => ['getex'], + 'PERSIST' => ['persist'], + 'APPEND' => ['append'], + 'STRLEN' => ['strlen'], + 'SCAN' => ['scan'], + ]; + private ParametersInterface $parameters; private KvOpsInterface $ops; @@ -187,10 +225,380 @@ private function dispatch(CommandInterface $command): mixed // ephpm KV is a single keyspace, so FLUSHDB and FLUSHALL are // equivalent — both clear the whole effective store. 'FLUSHDB', 'FLUSHALL' => $this->ops->flush() ? 'OK' : null, - default => throw new CommandNotSupportedException($id), + default => $this->dispatchExtended($id, $args), + }; + } + + /** + * Every command this connection executes against the current backend: + * {@see SUPPORTED_COMMANDS} plus whichever {@see EXTENDED_COMMANDS} the + * backend (i.e. the running ePHPm) supports. + * + * @return list + */ + public function supportedCommands(): array + { + $commands = self::SUPPORTED_COMMANDS; + foreach (self::EXTENDED_COMMANDS as $id => $ops) { + if ($this->supportsAll($ops)) { + $commands[] = $id; + } + } + return $commands; + } + + /** + * @param list $ops + */ + private function supportsAll(array $ops): bool + { + if (!$this->ops instanceof ExtendedKvOpsInterface) { + return false; + } + foreach ($ops as $op) { + if (!$this->ops->supports($op)) { + return false; + } + } + return true; + } + + private function unsupported(string $what): CommandNotSupportedException + { + return new CommandNotSupportedException($what, $this->supportedCommands()); + } + + /** + * The v0.13 commands. Anything not wired — or wired but missing from the + * running ePHPm — raises {@see CommandNotSupportedException}, the + * pre-v0.13 behavior for all of these. + * + * @param list $args + */ + private function dispatchExtended(string $id, array $args): mixed + { + $ops = $this->ops; + if (!isset(self::EXTENDED_COMMANDS[$id]) + || !$ops instanceof ExtendedKvOpsInterface + || !$this->supportsAll(self::EXTENDED_COMMANDS[$id]) + ) { + throw $this->unsupported($id); + } + $key = (string) ($args[0] ?? ''); + + return match ($id) { + 'HSET' => $this->doHset($ops, $args, false), + 'HMSET' => $this->doHset($ops, $args, true), + 'HSETNX' => $ops->hsetnx($key, (string) $args[1], (string) $args[2]) ? 1 : 0, + 'HGET' => $ops->hget($key, (string) $args[1]), + 'HMGET' => $this->orFail($ops->hmget($key, self::strings(\array_slice($args, 1))), 'ephpm_kv_hmget'), + 'HGETALL' => $this->doHgetall($ops, $key), + 'HDEL' => $ops->hdel($key, self::strings(\array_slice($args, 1))), + 'HEXISTS' => $ops->hexists($key, (string) $args[1]) ? 1 : 0, + 'HLEN' => $ops->hlen($key), + 'HKEYS' => self::strings($this->orFail($ops->hkeys($key), 'ephpm_kv_hkeys')), + 'HVALS' => self::strings($this->orFail($ops->hvals($key), 'ephpm_kv_hvals')), + 'HINCRBY' => $this->doHincrby($ops, $args), + 'HEXPIRE' => $this->doHexpire($ops, $args), + 'HTTL' => \array_map(static fn (string $f): int => $ops->httl($key, $f), self::fieldsArg($args, 1, $id)), + 'HPTTL' => \array_map(static fn (string $f): int => $ops->hpttl($key, $f), self::fieldsArg($args, 1, $id)), + 'HPERSIST' => $this->doHpersist($ops, $args), + 'MGET' => $this->orFail($ops->mget(self::strings($args)), 'ephpm_kv_mget'), + 'MSET' => $this->doMset($ops, $args), + 'GETDEL' => $ops->getdel($key), + 'GETEX' => $this->doGetex($ops, $args), + 'PERSIST' => $ops->persist($key) ? 1 : 0, + 'APPEND' => $this->orFail($ops->append($key, (string) $args[1]), 'ephpm_kv_append'), + 'STRLEN' => $ops->strlen($key), + 'SCAN' => $this->doScan($ops, $args), }; } + /** + * HSET key field value [field value …] → integer (fields created). + * HMSET (deprecated alias) → OK. Both write through the array form of + * `ephpm_kv_hset`; a refused write (memory limit, clustered field value + * over the size limit) raises a {@see ServerException}. + * + * @param list $args + */ + private function doHset(ExtendedKvOpsInterface $ops, array $args, bool $hmset): int|string + { + $pairs = \array_slice($args, 1); + if ($pairs === [] || \count($pairs) % 2 !== 0) { + $cmd = $hmset ? 'hmset' : 'hset'; + throw new ServerException("ERR wrong number of arguments for '{$cmd}' command"); + } + $fields = []; + for ($i = 0, $n = \count($pairs); $i < $n; $i += 2) { + $fields[(string) $pairs[$i]] = (string) $pairs[$i + 1]; + } + $created = $this->orFail($ops->hset((string) $args[0], $fields), 'ephpm_kv_hset'); + return $hmset ? 'OK' : $created; + } + + /** + * HGETALL — Predis' `HGETALL::parseResponse()` expects the raw RESP + * shape, a flat `[field, value, field, value, …]` list, and folds it into + * an associative array itself. + * + * @return list + */ + private function doHgetall(ExtendedKvOpsInterface $ops, string $key): array + { + $flat = []; + foreach ($this->orFail($ops->hgetall($key), 'ephpm_kv_hgetall') as $field => $value) { + $flat[] = (string) $field; + $flat[] = (string) $value; + } + return $flat; + } + + /** + * @param list $args + */ + private function doHincrby(ExtendedKvOpsInterface $ops, array $args): int + { + $result = $ops->hincrBy((string) $args[0], (string) $args[1], (int) $args[2]); + if ($result === false) { + throw new ServerException('ERR hash value is not an integer or out of range'); + } + return $result; + } + + /** + * HEXPIRE key seconds FIELDS numfields field … → one integer per field: + * 1 TTL set, -2 no such field, 2 field deleted (seconds = 0, i.e. the + * expiry is already due — `ephpm_kv_hexpire` refuses a TTL <= 0, so this + * deletes via `ephpm_kv_hdel` the way Redis does). Each field is handled + * on its own, so the reply is per field as Redis gives it. + * + * The NX/XX/GT/LT conditions would need a read-then-write the store + * cannot do atomically, so they stay unsupported rather than being + * ignored. (HPEXPIRE/HEXPIREAT/HPEXPIREAT are not wired at all.) + * + * @param list $args + * + * @return list + */ + private function doHexpire(ExtendedKvOpsInterface $ops, array $args): array + { + $key = (string) $args[0]; + $seconds = self::intArg($args[1] ?? null); + $at = 2; + if (isset($args[$at]) && \strtoupper((string) $args[$at]) !== 'FIELDS') { + $flag = \strtoupper((string) $args[$at]); + if (\in_array($flag, ['NX', 'XX', 'GT', 'LT'], true)) { + throw $this->unsupported("HEXPIRE {$flag}"); + } + throw new ServerException('ERR syntax error'); + } + $fields = self::fieldsArg($args, $at, 'HEXPIRE'); + if ($seconds < 0) { + throw new ServerException("ERR invalid expire time in 'hexpire' command"); + } + + $reply = []; + foreach ($fields as $field) { + if ($seconds === 0) { + $reply[] = $ops->hdel($key, [$field]) === 1 ? 2 : -2; + continue; + } + $set = $ops->hexpire($key, $field, $seconds); + if ($set === false) { + throw new ServerException("ERR invalid expire time in 'hexpire' command"); + } + $reply[] = $set === 1 ? 1 : -2; + } + return $reply; + } + + /** + * HPERSIST key FIELDS numfields field … → per field: 1 TTL removed, + * -1 field has no TTL, -2 no such field. + * + * @param list $args + * + * @return list + */ + private function doHpersist(ExtendedKvOpsInterface $ops, array $args): array + { + $key = (string) $args[0]; + $reply = []; + foreach (self::fieldsArg($args, 1, 'HPERSIST') as $field) { + if ($ops->hpersist($key, $field)) { + $reply[] = 1; + continue; + } + // false = no field or no TTL; tell the two apart. + $reply[] = $ops->hpttl($key, $field) === -2 ? -2 : -1; + } + return $reply; + } + + /** + * MSET key value [key value …] → OK, via `ephpm_kv_mset` (no TTL). + * + * @param list $args + */ + private function doMset(ExtendedKvOpsInterface $ops, array $args): string + { + if ($args === [] || \count($args) % 2 !== 0) { + throw new ServerException("ERR wrong number of arguments for 'mset' command"); + } + $values = []; + for ($i = 0, $n = \count($args); $i < $n; $i += 2) { + $values[(string) $args[$i]] = (string) $args[$i + 1]; + } + if (!$ops->mset($values, 0)) { + throw new ServerException( + 'ERR ephpm_kv_mset refused the write (memory limit or invalid key); ' + . 'keys before the refused one may already be stored' + ); + } + return 'OK'; + } + + /** + * GETEX key [EX seconds | PX milliseconds | PERSIST]. PX is accepted only + * when it is a whole number of seconds (the SAPI takes seconds and + * rounding would change the expiry); EXAT/PXAT and sub-second PX stay + * unsupported. + * + * @param list $args + */ + private function doGetex(ExtendedKvOpsInterface $ops, array $args): ?string + { + $key = (string) $args[0]; + if (\count($args) === 1) { + return $this->ops->get($key); + } + $modifier = \strtoupper((string) $args[1]); + if ($modifier === 'PERSIST' && \count($args) === 2) { + return $ops->getex($key, 0); + } + if (($modifier === 'EX' || $modifier === 'PX') && \count($args) === 3) { + $amount = self::intArg($args[2]); + if ($amount <= 0) { + throw new ServerException("ERR invalid expire time in 'getex' command"); + } + if ($modifier === 'PX') { + if ($amount % 1000 !== 0) { + throw $this->unsupported('GETEX PX (sub-second)'); + } + $amount = \intdiv($amount, 1000); + } + return $ops->getex($key, $amount); + } + if ($modifier === 'EXAT' || $modifier === 'PXAT') { + throw $this->unsupported("GETEX {$modifier}"); + } + throw new ServerException('ERR syntax error'); + } + + /** + * SCAN cursor [MATCH pattern] [COUNT count] → `[cursor-as-string, keys]`, + * the RESP shape Predis (and its `Keyspace` iterator) expects. In a + * cluster this lists this node's keyspace only — see the README. The + * TYPE filter is not supported. + * + * @param list $args + * + * @return array{0: string, 1: list} + */ + private function doScan(ExtendedKvOpsInterface $ops, array $args): array + { + $cursor = (string) ($args[0] ?? ''); + if (\preg_match('/^\d+$/', $cursor) !== 1) { + throw new ServerException('ERR invalid cursor'); + } + $match = null; + $count = 10; + for ($i = 1, $n = \count($args); $i < $n; $i += 2) { + $option = \strtoupper((string) $args[$i]); + if (!\array_key_exists($i + 1, $args)) { + throw new ServerException('ERR syntax error'); + } + switch ($option) { + case 'MATCH': + $match = (string) $args[$i + 1]; + break; + case 'COUNT': + $count = self::intArg($args[$i + 1]); + if ($count < 1) { + throw new ServerException('ERR syntax error'); + } + break; + case 'TYPE': + throw $this->unsupported('SCAN TYPE'); + default: + throw new ServerException('ERR syntax error'); + } + } + [$next, $keys] = $this->orFail($ops->scan((int) $cursor, $match, $count), 'ephpm_kv_scan'); + return [(string) $next, self::strings($keys)]; + } + + /** + * Parse a `FIELDS numfields field …` block starting at `$at`. + * + * @param list $args + * + * @return list + */ + private static function fieldsArg(array $args, int $at, string $cmd): array + { + if (\strtoupper((string) ($args[$at] ?? '')) !== 'FIELDS') { + throw new ServerException("ERR Mandatory argument FIELDS is missing or not at the right position for '" . \strtolower($cmd) . "'"); + } + $num = self::intArg($args[$at + 1] ?? null); + $fields = self::strings(\array_slice($args, $at + 2)); + if ($num < 1 || $num !== \count($fields)) { + throw new ServerException('ERR The `numfields` parameter must match the number of arguments'); + } + return $fields; + } + + private static function intArg(mixed $value): int + { + if (\is_int($value)) { + return $value; + } + if (\is_string($value) && \preg_match('/^-?\d+$/', $value) === 1) { + return (int) $value; + } + throw new ServerException('ERR value is not an integer or out of range'); + } + + /** + * @param array $values + * + * @return list + */ + private static function strings(array $values): array + { + return \array_map(static fn (mixed $v): string => (string) $v, \array_values($values)); + } + + /** + * Turn a SAPI failure value (`false`) into a {@see ServerException}. + * + * @template T + * + * @param T|false $result + * + * @return T + */ + private function orFail(mixed $result, string $function): mixed + { + if ($result === false) { + throw new ServerException( + "ERR {$function} failed (memory limit, oversized clustered hash field, invalid key, or no KV store)" + ); + } + return $result; + } + /** * SET key value [EX seconds | PX milliseconds] [NX] — the TTL modifiers * map directly to the SAPI's TTL parameter, and NX routes to the atomic @@ -207,10 +615,16 @@ private function dispatch(CommandInterface $command): mixed */ private function doSet(array $args): ?string { + // Predis' SET::setArguments() drops null/false modifiers without + // re-indexing; walk a dense list. + $args = \array_values($args); $key = (string) $args[0]; $value = (string) $args[1]; $ttl = 0; + $ttlGiven = false; $nx = false; + $keepTtl = false; + $ifeq = null; $i = 2; $count = \count($args); @@ -219,29 +633,49 @@ private function doSet(array $args): ?string switch ($modifier) { case 'EX': $ttl = (int) $args[$i + 1]; + $ttlGiven = true; $i += 2; break; case 'PX': // Round up so PX 1500 → 2 s rather than truncating to 1. $ms = (int) $args[$i + 1]; $ttl = (int) \ceil($ms / 1000); + $ttlGiven = true; $i += 2; break; case 'NX': $nx = true; $i += 1; break; + case 'KEEPTTL': + // Only expressible together with IFEQ (checked below). + $keepTtl = true; + $i += 1; + break; + case 'IFEQ': + if (!\array_key_exists($i + 1, $args)) { + throw new ServerException('ERR syntax error'); + } + $ifeq = (string) $args[$i + 1]; + $i += 2; + break; case 'XX': case 'GET': - case 'KEEPTTL': case 'EXAT': case 'PXAT': - throw new CommandNotSupportedException("SET {$modifier}"); + throw $this->unsupported("SET {$modifier}"); default: throw new \InvalidArgumentException("unknown SET modifier: {$modifier}"); } } + if ($ifeq !== null) { + return $this->doSetIfEq($key, $value, $ifeq, $ttl, $ttlGiven, $keepTtl, $nx); + } + if ($keepTtl) { + throw $this->unsupported('SET KEEPTTL'); + } + if ($nx) { // nil when not set (key already existed — the common case); // OOM is indistinguishable here and also surfaces as nil. @@ -256,6 +690,36 @@ private function doSet(array $args): ?string return 'OK'; } + /** + * SET key value IFEQ expected [EX s | PX ms | KEEPTTL] — Valkey's + * compare-and-set, via `ephpm_kv_set_if_eq` (ePHPm v0.13+). `OK` when + * the value matched and was replaced, nil otherwise (no such key, a + * different value, or — in a cluster — an unreachable key owner). As in + * Valkey, the key is never created and, without EX/PX/KEEPTTL, the TTL is + * cleared. PX rounds up to whole seconds exactly as for plain SET. + */ + private function doSetIfEq( + string $key, + string $value, + string $expected, + int $ttl, + bool $ttlGiven, + bool $keepTtl, + bool $nx, + ): ?string { + if (!$this->ops instanceof ExtendedKvOpsInterface || !$this->ops->supports('set_if_eq')) { + throw $this->unsupported('SET IFEQ'); + } + if ($nx || ($keepTtl && $ttlGiven)) { + throw new ServerException('ERR syntax error'); + } + if ($ttlGiven && $ttl <= 0) { + throw new ServerException("ERR invalid expire time in 'set' command"); + } + $ttlArg = $keepTtl ? -1 : $ttl; + return $this->ops->setIfEq($key, $expected, $value, $ttlArg) ? 'OK' : null; + } + /** * SETNX key value — insert only if absent. Redis integer reply: 1 when * inserted, 0 when the key already existed. A refused write (OOM) is also @@ -353,13 +817,17 @@ private function doPExpire(array $args): int } /** - * TYPE only ever returns "string" or "none" since the store has no - * other shapes to report — that's the whole point of the connection. + * TYPE — `ephpm_kv_type` ("string" | "hash" | "none") on ePHPm v0.13+. + * Older runtimes have no hashes, so there it is "string" if the key + * exists and "none" otherwise. * * @param list $args */ private function doType(array $args): string { + if ($this->ops instanceof ExtendedKvOpsInterface && $this->ops->supports('type')) { + return $this->ops->type((string) $args[0]); + } return $this->ops->exists((string) $args[0]) ? 'string' : 'none'; } } diff --git a/src/SapiKvOps.php b/src/SapiKvOps.php index 1531da6..403105e 100644 --- a/src/SapiKvOps.php +++ b/src/SapiKvOps.php @@ -9,8 +9,13 @@ * the ePHPm SAPI. Refuses to construct if those functions aren't present * so we fail fast outside the runtime instead of producing * "Call to undefined function" errors at request time. + * + * The v0.13 operations ({@see ExtendedKvOpsInterface}) are feature-detected + * per function: {@see supports()} reports whether the running ePHPm registers + * the backing `ephpm_kv_*` function, and {@see KvConnection} keeps its older + * behavior for a command whose function is missing. */ -final class SapiKvOps implements KvOpsInterface +final class SapiKvOps implements ExtendedKvOpsInterface { public function __construct() { @@ -90,4 +95,153 @@ public function flush(): bool } return (bool) \ephpm_kv_flush_all(); } + + // ── ePHPm v0.13+ (call only after supports() returned true) ───────────── + + public function supports(string $op): bool + { + return \in_array($op, self::OPS, true) && \function_exists('ephpm_kv_' . $op); + } + + public function hget(string $key, string $field): ?string + { + /** @var string|null */ + return \ephpm_kv_hget($key, $field); + } + + public function hset(string $key, array $fields): int|false + { + /** @var int|false */ + return \ephpm_kv_hset($key, $fields); + } + + public function hsetnx(string $key, string $field, string $value): bool + { + return (bool) \ephpm_kv_hsetnx($key, $field, $value); + } + + public function hmget(string $key, array $fields): array|false + { + /** @var list|false */ + return \ephpm_kv_hmget($key, $fields); + } + + public function hgetall(string $key): array|false + { + /** @var array|false */ + return \ephpm_kv_hgetall($key); + } + + public function hdel(string $key, array $fields): int + { + return (int) \ephpm_kv_hdel($key, $fields); + } + + public function hexists(string $key, string $field): bool + { + return (bool) \ephpm_kv_hexists($key, $field); + } + + public function hlen(string $key): int + { + return (int) \ephpm_kv_hlen($key); + } + + public function hkeys(string $key): array|false + { + /** @var list|false */ + return \ephpm_kv_hkeys($key); + } + + public function hvals(string $key): array|false + { + /** @var list|false */ + return \ephpm_kv_hvals($key); + } + + public function hincrBy(string $key, string $field, int $delta): int|false + { + /** @var int|false */ + return \ephpm_kv_hincr_by($key, $field, $delta); + } + + public function hexpire(string $key, string $field, int $ttlSeconds): int|false + { + /** @var int|false */ + return \ephpm_kv_hexpire($key, $field, $ttlSeconds); + } + + public function httl(string $key, string $field): int + { + return (int) \ephpm_kv_httl($key, $field); + } + + public function hpttl(string $key, string $field): int + { + return (int) \ephpm_kv_hpttl($key, $field); + } + + public function hpersist(string $key, string $field): bool + { + return (bool) \ephpm_kv_hpersist($key, $field); + } + + public function mget(array $keys): array|false + { + /** @var list|false */ + return \ephpm_kv_mget($keys); + } + + public function mset(array $values, int $ttlSeconds = 0): bool + { + return (bool) \ephpm_kv_mset($values, $ttlSeconds); + } + + public function getdel(string $key): ?string + { + /** @var string|null */ + return \ephpm_kv_getdel($key); + } + + public function getex(string $key, int $ttlSeconds): ?string + { + /** @var string|null */ + return \ephpm_kv_getex($key, $ttlSeconds); + } + + public function persist(string $key): bool + { + return (bool) \ephpm_kv_persist($key); + } + + public function setIfEq(string $key, string $expected, string $value, int $ttlSeconds = 0): bool + { + return (bool) \ephpm_kv_set_if_eq($key, $expected, $value, $ttlSeconds); + } + + public function append(string $key, string $value): int|false + { + /** @var int|false */ + return \ephpm_kv_append($key, $value); + } + + public function strlen(string $key): int + { + return (int) \ephpm_kv_strlen($key); + } + + public function type(string $key): string + { + return (string) \ephpm_kv_type($key); + } + + public function scan(int $cursor, ?string $match = null, int $count = 10): array|false + { + $page = \ephpm_kv_scan($cursor, $match, $count); + if (!\is_array($page)) { + return false; + } + /** @var array{0: int, 1: array} $page */ + return [(int) $page[0], \array_values($page[1])]; + } } diff --git a/tests/KvConnectionExtendedTest.php b/tests/KvConnectionExtendedTest.php new file mode 100644 index 0000000..7cf99d4 --- /dev/null +++ b/tests/KvConnectionExtendedTest.php @@ -0,0 +1,470 @@ +|null $ops + */ + private function client(?array $ops = null): Client + { + $this->ops = new InMemoryKvOps($ops); + return new Client(new KvConnection(null, $this->ops)); + } + + /** + * @param list $args + */ + private function raw(Client $client, string $id, array $args): mixed + { + return $client->executeCommand($client->createCommand($id, $args)); + } + + // ── hashes ─────────────────────────────────────────────────────────────── + + public function test_hset_returns_created_count_and_hget_reads_back(): void + { + $client = $this->client(); + self::assertSame(1, $client->hset('h', 'a', '1')); + self::assertSame(0, $client->hset('h', 'a', '2')); + self::assertSame(1, $this->raw($client, 'HSET', ['h', 'a', '3', 'b', '4'])); + self::assertSame('3', $client->hget('h', 'a')); + self::assertNull($client->hget('h', 'missing')); + self::assertNull($client->hget('nohash', 'a')); + } + + public function test_hset_with_odd_field_value_list_is_an_error(): void + { + $this->expectException(ServerException::class); + $this->raw($this->client(), 'HSET', ['h', 'a']); + } + + public function test_hset_refused_write_raises(): void + { + $client = $this->client(); + $this->ops->simulateOom(); + $this->expectException(ServerException::class); + $this->expectExceptionMessageMatches('/ephpm_kv_hset failed/'); + $client->hset('h', 'a', '1'); + } + + public function test_hmset_array_form_returns_ok(): void + { + $client = $this->client(); + self::assertSame('OK', $client->hmset('h', ['a' => '1', 'b' => '2'])); + self::assertSame(['1', null, '2'], $client->hmget('h', ['a', 'zz', 'b'])); + self::assertSame([null, null], $client->hmget('nohash', 'a', 'b')); + } + + public function test_hgetall_is_parsed_by_predis_into_an_assoc_array(): void + { + $client = $this->client(); + $client->hmset('h', ['a' => '1', 'b' => '2', '7' => 'seven']); + self::assertEquals(['a' => '1', 'b' => '2', '7' => 'seven'], $client->hgetall('h')); + self::assertSame([], $client->hgetall('nohash')); + } + + public function test_hdel_hexists_hlen_hkeys_hvals(): void + { + $client = $this->client(); + $client->hmset('h', ['a' => '1', 'b' => '2', '3' => 'c']); + self::assertSame(1, $client->hexists('h', 'a')); + self::assertSame(0, $client->hexists('h', 'zz')); + self::assertSame(3, $client->hlen('h')); + self::assertSame(['a', 'b', '3'], $client->hkeys('h')); + self::assertSame(['1', '2', 'c'], $client->hvals('h')); + self::assertSame(2, $client->hdel('h', ['a', 'b', 'zz'])); + self::assertSame(1, $client->hlen('h')); + self::assertSame(0, $client->hlen('nohash')); + self::assertSame([], $client->hkeys('nohash')); + } + + public function test_hsetnx_returns_one_then_zero(): void + { + $client = $this->client(); + self::assertSame(1, $client->hsetnx('h', 'f', 'a')); + self::assertSame(0, $client->hsetnx('h', 'f', 'b')); + self::assertSame('a', $client->hget('h', 'f')); + } + + public function test_hincrby_returns_new_value_and_rejects_non_integer(): void + { + $client = $this->client(); + self::assertSame(5, $client->hincrby('h', 'n', 5)); + self::assertSame(3, $client->hincrby('h', 'n', -2)); + $client->hset('h', 's', 'abc'); + $this->expectException(ServerException::class); + $client->hincrby('h', 's', 1); + } + + public function test_hexpire_replies_per_field_and_httl_hpttl_hpersist(): void + { + $client = $this->client(); + $client->hmset('h', ['a' => '1', 'b' => '2']); + self::assertSame([1, -2], $client->hexpire('h', 60, ['a', 'missing'])); + self::assertSame([60, -1, -2], $client->httl('h', ['a', 'b', 'missing'])); + [$ms] = $client->hpttl('h', ['a']); + self::assertGreaterThan(59_000, $ms); + self::assertSame([1, -1, -2], $client->hpersist('h', ['a', 'b', 'missing'])); + self::assertSame([-1], $client->httl('h', ['a'])); + } + + public function test_hexpire_zero_deletes_the_field_like_redis(): void + { + $client = $this->client(); + $client->hset('h', 'a', '1'); + self::assertSame([2, -2], $client->hexpire('h', 0, ['a', 'missing'])); + self::assertSame(0, $client->hexists('h', 'a')); + } + + public function test_hexpire_negative_ttl_is_an_error(): void + { + $client = $this->client(); + $client->hset('h', 'a', '1'); + $this->expectException(ServerException::class); + $client->hexpire('h', -1, ['a']); + } + + /** + * @return iterable + */ + public static function hexpireConditions(): iterable + { + foreach (['NX', 'XX', 'GT', 'LT'] as $flag) { + yield $flag => [$flag]; + } + } + + #[DataProvider('hexpireConditions')] + public function test_hexpire_conditions_are_rejected_not_ignored(string $flag): void + { + $client = $this->client(); + $client->hset('h', 'a', '1'); + $this->expectException(CommandNotSupportedException::class); + $this->expectExceptionMessageMatches("/HEXPIRE {$flag}/"); + $client->hexpire('h', 60, ['a'], $flag); + } + + public function test_hpexpire_stays_unsupported(): void + { + $this->expectException(CommandNotSupportedException::class); + $this->client()->hpexpire('h', 1500, ['a']); + } + + public function test_type_reports_hash(): void + { + $client = $this->client(); + $client->hset('h', 'a', '1'); + $client->set('s', 'v'); + self::assertSame('hash', $client->type('h')); + self::assertSame('string', $client->type('s')); + self::assertSame('none', $client->type('nope')); + } + + public function test_del_removes_a_hash(): void + { + $client = $this->client(); + $client->hset('h', 'a', '1'); + self::assertSame(1, $client->del('h')); + self::assertSame(0, $client->hlen('h')); + } + + // ── strings ────────────────────────────────────────────────────────────── + + public function test_mset_and_mget(): void + { + $client = $this->client(); + self::assertSame('OK', $client->mset(['a' => '1', 'b' => '2', '10' => 'ten'])); + self::assertSame(['1', null, '2', 'ten'], $client->mget(['a', 'zz', 'b', '10'])); + self::assertSame(['1', '2'], $client->mget('a', 'b')); + self::assertSame(-1, $client->ttl('a')); + } + + public function test_mset_refused_write_raises(): void + { + $client = $this->client(); + $this->ops->simulateOom(); + $this->expectException(ServerException::class); + $client->mset(['a' => '1']); + } + + public function test_getdel_returns_value_and_deletes(): void + { + $client = $this->client(); + $client->set('k', 'v'); + self::assertSame('v', $client->getdel('k')); + self::assertNull($client->get('k')); + self::assertNull($client->getdel('k')); + } + + public function test_getex_ex_px_persist_and_plain(): void + { + $client = $this->client(); + $client->set('k', 'v'); + self::assertSame('v', $client->getex('k')); + self::assertSame(-1, $client->ttl('k')); + self::assertSame('v', $client->getex('k', 'ex', 60)); + self::assertSame(60, $client->ttl('k')); + self::assertSame('v', $client->getex('k', 'px', 120_000)); + self::assertSame(120, $client->ttl('k')); + self::assertSame('v', $client->getex('k', 'persist')); + self::assertSame(-1, $client->ttl('k')); + self::assertNull($client->getex('missing', 'ex', 60)); + } + + public function test_getex_sub_second_px_is_not_rounded(): void + { + $client = $this->client(); + $client->set('k', 'v'); + $this->expectException(CommandNotSupportedException::class); + $this->expectExceptionMessageMatches('/GETEX PX/'); + $client->getex('k', 'px', 1500); + } + + public function test_getex_zero_ex_is_an_error_not_a_persist(): void + { + $client = $this->client(); + $client->set('k', 'v', 'EX', 60); + try { + $client->getex('k', 'ex', 0); + self::fail('expected ServerException'); + } catch (ServerException) { + self::assertSame(60, $client->ttl('k')); + } + } + + public function test_getex_exat_stays_unsupported(): void + { + $this->expectException(CommandNotSupportedException::class); + $this->client()->getex('k', 'exat', 9_999_999_999); + } + + public function test_persist(): void + { + $client = $this->client(); + $client->set('k', 'v', 'EX', 60); + self::assertSame(1, $client->persist('k')); + self::assertSame(0, $client->persist('k')); + self::assertSame(-1, $client->ttl('k')); + } + + public function test_append_and_strlen(): void + { + $client = $this->client(); + self::assertSame(3, $client->append('k', 'abc')); + self::assertSame(5, $client->append('k', 'de')); + self::assertSame('abcde', $client->get('k')); + self::assertSame(5, $client->strlen('k')); + self::assertSame(0, $client->strlen('missing')); + } + + // ── SCAN ───────────────────────────────────────────────────────────────── + + public function test_scan_returns_string_cursor_and_iterates_every_key(): void + { + $client = $this->client(); + for ($i = 0; $i < 25; $i++) { + $client->set("user:{$i}", 'x'); + } + $client->set('other', 'x'); + $client->hset('user:hash', 'f', 'v'); + + $seen = []; + $cursor = '0'; + do { + $page = $client->scan($cursor, ['MATCH' => 'user:*', 'COUNT' => 7]); + self::assertIsString($page[0]); + [$cursor, $keys] = $page; + \array_push($seen, ...$keys); + } while ($cursor !== '0'); + + \sort($seen); + self::assertCount(26, $seen); + self::assertNotContains('other', $seen); + self::assertContains('user:hash', $seen); + } + + public function test_scan_works_with_the_predis_keyspace_iterator(): void + { + $client = $this->client(); + $client->mset(['a:1' => 'x', 'a:2' => 'x', 'b:1' => 'x']); + $keys = \iterator_to_array(new \Predis\Collection\Iterator\Keyspace($client, 'a:*', 1), false); + \sort($keys); + self::assertSame(['a:1', 'a:2'], $keys); + } + + public function test_scan_type_filter_is_unsupported(): void + { + $this->expectException(CommandNotSupportedException::class); + $this->raw($this->client(), 'SCAN', ['0', 'TYPE', 'hash']); + } + + public function test_scan_rejects_a_bad_cursor(): void + { + $this->expectException(ServerException::class); + $this->raw($this->client(), 'SCAN', ['abc']); + } + + // ── SET … IFEQ ─────────────────────────────────────────────────────────── + + public function test_set_ifeq_replaces_only_on_match_and_never_creates(): void + { + $client = $this->client(); + self::assertNull($client->set('k', 'new', 'IFEQ', 'x')); + self::assertNull($client->get('k')); + + $client->set('k', 'tok'); + self::assertNull($client->set('k', 'new', 'IFEQ', 'other')); + self::assertSame('tok', $client->get('k')); + self::assertSame('OK', $client->set('k', 'new', 'IFEQ', 'tok', 'EX', 30)); + self::assertSame('new', $client->get('k')); + self::assertSame(30, $client->ttl('k')); + } + + public function test_set_ifeq_keepttl_keeps_and_plain_clears_the_ttl(): void + { + $client = $this->client(); + $client->set('k', 'a', 'EX', 60); + self::assertSame('OK', $client->set('k', 'b', 'IFEQ', 'a', 'KEEPTTL')); + self::assertSame(60, $client->ttl('k')); + self::assertSame('OK', $client->set('k', 'c', 'IFEQ', 'b')); + self::assertSame(-1, $client->ttl('k')); + } + + public function test_set_ifeq_with_nx_is_a_syntax_error(): void + { + $client = $this->client(); + $client->set('k', 'a'); + $this->expectException(ServerException::class); + $client->set('k', 'b', 'IFEQ', 'a', 'NX'); + } + + public function test_keepttl_without_ifeq_stays_unsupported(): void + { + $this->expectException(CommandNotSupportedException::class); + $this->expectExceptionMessageMatches('/SET KEEPTTL/'); + $this->client()->set('k', 'v', 'KEEPTTL'); + } + + // ── fallback: an ePHPm without the v0.13 functions ─────────────────────── + + /** + * @return iterable}> + */ + public static function extendedCommands(): iterable + { + yield 'HSET' => ['HSET', ['h', 'f', 'v']]; + yield 'HMSET' => ['HMSET', ['h', 'f', 'v']]; + yield 'HSETNX' => ['HSETNX', ['h', 'f', 'v']]; + yield 'HGET' => ['HGET', ['h', 'f']]; + yield 'HMGET' => ['HMGET', ['h', 'f']]; + yield 'HGETALL' => ['HGETALL', ['h']]; + yield 'HDEL' => ['HDEL', ['h', 'f']]; + yield 'HEXISTS' => ['HEXISTS', ['h', 'f']]; + yield 'HLEN' => ['HLEN', ['h']]; + yield 'HKEYS' => ['HKEYS', ['h']]; + yield 'HVALS' => ['HVALS', ['h']]; + yield 'HINCRBY' => ['HINCRBY', ['h', 'f', 1]]; + yield 'HEXPIRE' => ['HEXPIRE', ['h', 60, ['f']]]; + yield 'HTTL' => ['HTTL', ['h', ['f']]]; + yield 'HPTTL' => ['HPTTL', ['h', ['f']]]; + yield 'HPERSIST' => ['HPERSIST', ['h', ['f']]]; + yield 'MGET' => ['MGET', ['a', 'b']]; + yield 'MSET' => ['MSET', ['a', '1']]; + yield 'GETDEL' => ['GETDEL', ['a']]; + yield 'GETEX' => ['GETEX', ['a', 'ex', 5]]; + yield 'PERSIST' => ['PERSIST', ['a']]; + yield 'APPEND' => ['APPEND', ['a', 'x']]; + yield 'STRLEN' => ['STRLEN', ['a']]; + yield 'SCAN' => ['SCAN', [0]]; + } + + /** + * @param list $args + */ + #[DataProvider('extendedCommands')] + public function test_without_v013_functions_the_command_stays_unsupported(string $id, array $args): void + { + $client = $this->client([]); + try { + $this->raw($client, $id, $args); + self::fail("expected CommandNotSupportedException for {$id}"); + } catch (CommandNotSupportedException $e) { + self::assertStringContainsString("'{$id}'", $e->getMessage()); + } + } + + public function test_every_extended_command_has_a_fallback_case(): void + { + self::assertSame( + \array_keys(KvConnection::EXTENDED_COMMANDS), + \array_keys(\iterator_to_array(self::extendedCommands())), + ); + } + + public function test_one_missing_function_disables_only_its_command(): void + { + $ops = \array_values(\array_diff(ExtendedKvOpsInterface::OPS, ['hdel'])); + $client = $this->client($ops); + self::assertSame(1, $client->hset('h', 'f', 'v')); + $this->expectException(CommandNotSupportedException::class); + // HEXPIRE needs hdel too (for the seconds = 0 case). + $client->hexpire('h', 60, ['f']); + } + + public function test_without_v013_functions_type_falls_back_to_exists(): void + { + $client = $this->client([]); + $client->set('s', 'v'); + self::assertSame('string', $client->type('s')); + self::assertSame('none', $client->type('nope')); + } + + public function test_without_v013_functions_set_ifeq_is_unsupported(): void + { + $client = $this->client([]); + $client->set('k', 'a'); + $this->expectException(CommandNotSupportedException::class); + $this->expectExceptionMessageMatches('/SET IFEQ/'); + $client->set('k', 'b', 'IFEQ', 'a'); + } + + public function test_unsupported_message_lists_the_commands_actually_available(): void + { + try { + $this->raw($this->client(), 'LPUSH', ['l', 'x']); + self::fail('expected CommandNotSupportedException'); + } catch (CommandNotSupportedException $e) { + self::assertStringContainsString('HGETALL', $e->getMessage()); + } + try { + $this->raw($this->client([]), 'LPUSH', ['l', 'x']); + self::fail('expected CommandNotSupportedException'); + } catch (CommandNotSupportedException $e) { + self::assertStringNotContainsString('HGETALL', $e->getMessage()); + } + } +} diff --git a/tests/SapiKvOpsTest.php b/tests/SapiKvOpsTest.php index 59f170e..91c6aef 100644 --- a/tests/SapiKvOpsTest.php +++ b/tests/SapiKvOpsTest.php @@ -68,4 +68,31 @@ public function test_set_reports_false_under_oom(): void SapiStubKv::$oom = true; self::assertFalse((new SapiKvOps())->set('k', 'v')); } + + public function test_supports_tracks_which_functions_exist(): void + { + $ops = new SapiKvOps(); + self::assertTrue($ops->supports('hset')); + self::assertTrue($ops->supports('mget')); + // Not stubbed — i.e. "this ePHPm does not have it". + self::assertFalse($ops->supports('hpersist')); + // Existing functions are not v0.13 operations. + self::assertFalse($ops->supports('get')); + self::assertFalse($ops->supports('bogus')); + } + + public function test_hash_and_multi_key_calls_pass_through(): void + { + $ops = new SapiKvOps(); + self::assertSame(2, $ops->hset('h', ['a' => '1', 'b' => '2'])); + self::assertSame('1', $ops->hget('h', 'a')); + self::assertSame(['a' => '1', 'b' => '2'], $ops->hgetall('h')); + self::assertTrue($ops->mset(['x' => '1', 'y' => '2'])); + self::assertSame(['1', null, '2'], $ops->mget(['x', 'zz', 'y'])); + } + + public function test_scan_relists_the_key_page(): void + { + self::assertSame([0, ['a', 'b']], (new SapiKvOps())->scan(0, 'x*', 10)); + } } diff --git a/tests/sapi_kv_stubs.php b/tests/sapi_kv_stubs.php index f43378c..cd2bb54 100644 --- a/tests/sapi_kv_stubs.php +++ b/tests/sapi_kv_stubs.php @@ -31,9 +31,13 @@ final class SapiStubKv /** When true, ephpm_kv_set / ephpm_kv_setnx refuse the write (OOM). */ public static bool $oom = false; + /** @var array> */ + public static array $hashes = []; + public static function reset(): void { self::$values = []; + self::$hashes = []; self::$incrByReturn = null; self::$oom = false; } @@ -82,4 +86,49 @@ function ephpm_kv_incr_by(string $key, int $delta): int|false return $next; } } + + // A subset of the ePHPm v0.13 functions — enough to check SapiKvOps' + // pass-through and shape normalisation. The rest (e.g. ephpm_kv_hpersist) + // are deliberately NOT stubbed, so supports() can be seen reporting false. + if (!\function_exists('ephpm_kv_hget')) { + function ephpm_kv_hget(string $key, string $field): ?string + { + return SapiStubKv::$hashes[$key][$field] ?? null; + } + + function ephpm_kv_hset(string $key, string|array $field, ?string $value = null): int|false + { + $fields = \is_array($field) ? $field : [$field => $value]; + $created = 0; + foreach ($fields as $f => $v) { + $created += isset(SapiStubKv::$hashes[$key][$f]) ? 0 : 1; + SapiStubKv::$hashes[$key][$f] = (string) $v; + } + return $created; + } + + function ephpm_kv_hgetall(string $key): array|false + { + return SapiStubKv::$hashes[$key] ?? []; + } + + function ephpm_kv_mget(array $keys): array|false + { + return \array_map(static fn ($k) => SapiStubKv::$values[$k] ?? null, \array_values($keys)); + } + + function ephpm_kv_mset(array $values, int $ttl = 0): bool + { + foreach ($values as $k => $v) { + SapiStubKv::$values[(string) $k] = (string) $v; + } + return true; + } + + function ephpm_kv_scan(int $cursor, ?string $match = null, int $count = 10): array|false + { + // Returns keys keyed by position, to prove SapiKvOps re-lists them. + return [0, [5 => 'a', 9 => 'b']]; + } + } }