diff --git a/src/voku/cache/AdapterApc.php b/src/voku/cache/AdapterApc.php index d92a13a..6ba1333 100644 --- a/src/voku/cache/AdapterApc.php +++ b/src/voku/cache/AdapterApc.php @@ -149,6 +149,26 @@ public function removeAll(): bool return (bool) ($this->cacheClear('system') && $this->cacheClear('user')); } + /** + * {@inheritdoc} + */ + public function getAllKeys(): array + { + $info = $this->cacheInfo('user'); + if (empty($info['cache_list'])) { + return []; + } + + $keys = []; + foreach ($info['cache_list'] as $entry) { + if (isset($entry['info'])) { + $keys[] = (string) $entry['info']; + } + } + + return $keys; + } + /** * {@inheritdoc} */ diff --git a/src/voku/cache/AdapterApcu.php b/src/voku/cache/AdapterApcu.php index a93dd0e..0333a22 100644 --- a/src/voku/cache/AdapterApcu.php +++ b/src/voku/cache/AdapterApcu.php @@ -144,6 +144,26 @@ public function removeAll(): bool return (bool) ($this->cacheClear('system') && $this->cacheClear('user')); } + /** + * {@inheritdoc} + */ + public function getAllKeys(): array + { + $info = $this->cacheInfo(); + if (empty($info['cache_list'])) { + return []; + } + + $keys = []; + foreach ($info['cache_list'] as $entry) { + if (isset($entry['key'])) { + $keys[] = (string) $entry['key']; + } + } + + return $keys; + } + /** * {@inheritdoc} */ diff --git a/src/voku/cache/AdapterArray.php b/src/voku/cache/AdapterArray.php index 42331ea..b9f7700 100644 --- a/src/voku/cache/AdapterArray.php +++ b/src/voku/cache/AdapterArray.php @@ -88,6 +88,14 @@ public function removeAll(): bool return true; } + /** + * {@inheritdoc} + */ + public function getAllKeys(): array + { + return \array_keys(self::$values); + } + /** * {@inheritdoc} */ diff --git a/src/voku/cache/AdapterFileAbstract.php b/src/voku/cache/AdapterFileAbstract.php index b19730b..0fc5754 100644 --- a/src/voku/cache/AdapterFileAbstract.php +++ b/src/voku/cache/AdapterFileAbstract.php @@ -175,6 +175,48 @@ public function removeAll(): bool return \in_array(false, $return, true) === false; } + /** + * {@inheritdoc} + * + *

Scans the cache directory and returns the store keys of all currently cached files + * by stripping CACHE_FILE_PREFIX and CACHE_FILE_SUBFIX from each filename. + * When used through the {@link Cache} class the keys will be MD5 hashes because + * {@link Cache::calculateStoreKey()} hashes the raw key for file-based adapters.

+ */ + public function getAllKeys(): array + { + if (!$this->cacheDir || !\is_dir($this->cacheDir)) { + return []; + } + + $prefix = self::CACHE_FILE_PREFIX; + $suffix = self::CACHE_FILE_SUBFIX; + $prefixLen = \strlen($prefix); + $suffixLen = \strlen($suffix); + $keys = []; + + foreach (new \DirectoryIterator($this->cacheDir) as $fileInfo) { + if ($fileInfo->isDot() || !$fileInfo->isFile()) { + continue; + } + + $filename = $fileInfo->getFilename(); + + if ( + \str_starts_with($filename, $prefix) + && + \str_ends_with($filename, $suffix) + ) { + $key = \substr($filename, $prefixLen, -$suffixLen); + if ($key !== '') { + $keys[] = $key; + } + } + } + + return $keys; + } + /** * {@inheritdoc} */ diff --git a/src/voku/cache/AdapterMemcache.php b/src/voku/cache/AdapterMemcache.php index 29eec0c..5b3a34f 100644 --- a/src/voku/cache/AdapterMemcache.php +++ b/src/voku/cache/AdapterMemcache.php @@ -12,6 +12,12 @@ */ class AdapterMemcache implements iAdapter { + /** + * Internal key used to persist the key registry inside Memcache. + * Not intended for use by application code. + */ + private const KEYS_REGISTRY_KEY = '__memcache_adapter_keys__'; + /** * @var bool */ @@ -77,17 +83,41 @@ public function installed(): bool */ public function remove(string $key): bool { - return $this->memcache->delete($key); + $result = $this->memcache->delete($key); + + $keys = $this->getKeysRegistry(); + $filtered = \array_values(\array_diff($keys, [$key])); + if (\count($filtered) !== \count($keys)) { + $this->saveKeysRegistry($filtered); + } + + return $result; } /** * {@inheritdoc} + * + *

Calling flush() clears the entire Memcache backend, which includes the key + * registry stored under {@link KEYS_REGISTRY_KEY}. The registry is therefore + * implicitly empty after this call, consistent with the contract of getAllKeys().

*/ public function removeAll(): bool { return $this->memcache->flush(); } + /** + * {@inheritdoc} + * + *

Returns the list of keys that have been stored through this adapter instance. + * The registry is maintained inside Memcache under an internal key and is updated + * on every {@link set()}, {@link setExpired()}, and {@link remove()} call.

+ */ + public function getAllKeys(): array + { + return $this->getKeysRegistry(); + } + /** * {@inheritdoc} */ @@ -98,7 +128,17 @@ public function set(string $key, $value): bool throw new InvalidArgumentException('The passed cache key is over 250 bytes:' . \print_r($key, true)); } - return $this->memcache->set($key, $value, $this->getCompressedFlag()); + $result = $this->memcache->set($key, $value, $this->getCompressedFlag()); + + if ($result) { + $keys = $this->getKeysRegistry(); + if (!\in_array($key, $keys, true)) { + $keys[] = $key; + $this->saveKeysRegistry($keys); + } + } + + return $result; } /** @@ -110,7 +150,17 @@ public function setExpired(string $key, $value, int $ttl = 0): bool $ttl = 2592000; } - return $this->memcache->set($key, $value, $this->getCompressedFlag(), $ttl); + $result = $this->memcache->set($key, $value, $this->getCompressedFlag(), $ttl); + + if ($result) { + $keys = $this->getKeysRegistry(); + if (!\in_array($key, $keys, true)) { + $keys[] = $key; + $this->saveKeysRegistry($keys); + } + } + + return $result; } /** @@ -142,4 +192,39 @@ public function setCompressed($value) { $this->compressed = (bool) $value; } + + /** + * Read the key registry stored inside Memcache. + * + * @return string[] + */ + private function getKeysRegistry(): array + { + $stored = $this->memcache->get(self::KEYS_REGISTRY_KEY); + if ($stored === false || !\is_string($stored)) { + return []; + } + + $keys = @\unserialize($stored, ['allowed_classes' => false]); + + return \is_array($keys) ? $keys : []; + } + + /** + * Persist the key registry into Memcache (no TTL so it survives as long as the server allows). + * + * @param string[] $keys + * + * @return void + */ + private function saveKeysRegistry(array $keys): void + { + if (empty($keys)) { + $this->memcache->delete(self::KEYS_REGISTRY_KEY); + + return; + } + + $this->memcache->set(self::KEYS_REGISTRY_KEY, \serialize(\array_values($keys)), 0, 0); + } } diff --git a/src/voku/cache/AdapterMemcached.php b/src/voku/cache/AdapterMemcached.php index eb2e273..895e7ee 100644 --- a/src/voku/cache/AdapterMemcached.php +++ b/src/voku/cache/AdapterMemcached.php @@ -85,6 +85,19 @@ public function removeAll(): bool return $this->memcached->flush(); } + /** + * {@inheritdoc} + */ + public function getAllKeys(): array + { + $keys = $this->memcached->getAllKeys(); + if ($keys === false) { + return []; + } + + return $keys; + } + /** * {@inheritdoc} */ diff --git a/src/voku/cache/AdapterOpCache.php b/src/voku/cache/AdapterOpCache.php index 67a8afd..efa2528 100644 --- a/src/voku/cache/AdapterOpCache.php +++ b/src/voku/cache/AdapterOpCache.php @@ -129,4 +129,48 @@ public function setExpired(string $key, $value, int $ttl = 0): bool return $result; } + + /** + * {@inheritdoc} + * + *

Overrides the base implementation because {@link AdapterOpCache} stores files with a + * .php extension instead of the .php.cache extension used by + * the other file-based adapters.

+ */ + public function getAllKeys(): array + { + if (!$this->cacheDir || !\is_dir($this->cacheDir)) { + return []; + } + + $prefix = static::CACHE_FILE_PREFIX; + $suffix = '.php'; + $prefixLen = \strlen($prefix); + $suffixLen = \strlen($suffix); + $keys = []; + + foreach (new \DirectoryIterator($this->cacheDir) as $fileInfo) { + if ($fileInfo->isDot() || !$fileInfo->isFile()) { + continue; + } + + $filename = $fileInfo->getFilename(); + + // Match __simple_KEY.php but not __simple_KEY.php.cache + if ( + \str_starts_with($filename, $prefix) + && + \str_ends_with($filename, $suffix) + && + !\str_ends_with($filename, '.php.cache') + ) { + $key = \substr($filename, $prefixLen, -$suffixLen); + if ($key !== '') { + $keys[] = $key; + } + } + } + + return $keys; + } } diff --git a/src/voku/cache/AdapterPredis.php b/src/voku/cache/AdapterPredis.php index ff06428..a0eb2d4 100644 --- a/src/voku/cache/AdapterPredis.php +++ b/src/voku/cache/AdapterPredis.php @@ -82,6 +82,17 @@ public function removeAll(): bool return (bool) $this->client->flushall(); } + /** + * {@inheritdoc} + * + *

Uses the Redis KEYS * command, which scans the entire keyspace. + * This may have performance implications on large Redis datasets.

+ */ + public function getAllKeys(): array + { + return (array) $this->client->keys('*'); + } + /** * {@inheritdoc} */ diff --git a/src/voku/cache/AdapterXcache.php b/src/voku/cache/AdapterXcache.php index 4ea1e83..def16b8 100644 --- a/src/voku/cache/AdapterXcache.php +++ b/src/voku/cache/AdapterXcache.php @@ -9,6 +9,12 @@ */ class AdapterXcache implements iAdapter { + /** + * Internal key used to persist the key registry inside Xcache. + * Not intended for use by application code. + */ + private const KEYS_REGISTRY_KEY = '__xcache_adapter_keys__'; + /** * @var bool */ @@ -53,11 +59,23 @@ public function installed(): bool */ public function remove(string $key): bool { - return \xcache_unset($key); + $result = \xcache_unset($key); + + $keys = $this->getKeysRegistry(); + $filtered = \array_values(\array_diff($keys, [$key])); + if (\count($filtered) !== \count($keys)) { + $this->saveKeysRegistry($filtered); + } + + return $result; } /** * {@inheritdoc} + * + *

xcache_clear_cache() wipes the entire Xcache variable store, which includes + * the key registry stored under {@link KEYS_REGISTRY_KEY}. The registry is therefore + * implicitly empty after this call, consistent with the contract of getAllKeys().

*/ public function removeAll(): bool { @@ -73,12 +91,34 @@ public function removeAll(): bool return false; } + /** + * {@inheritdoc} + * + *

Returns the list of keys that have been stored through this adapter instance. + * The registry is maintained inside Xcache under an internal key and is updated + * on every {@link set()}, {@link setExpired()}, and {@link remove()} call.

+ */ + public function getAllKeys(): array + { + return $this->getKeysRegistry(); + } + /** * {@inheritdoc} */ public function set(string $key, $value): bool { - return \xcache_set($key, $value); + $result = \xcache_set($key, $value); + + if ($result) { + $keys = $this->getKeysRegistry(); + if (!\in_array($key, $keys, true)) { + $keys[] = $key; + $this->saveKeysRegistry($keys); + } + } + + return $result; } /** @@ -86,6 +126,55 @@ public function set(string $key, $value): bool */ public function setExpired(string $key, $value, int $ttl = 0): bool { - return \xcache_set($key, $value, $ttl); + $result = \xcache_set($key, $value, $ttl); + + if ($result) { + $keys = $this->getKeysRegistry(); + if (!\in_array($key, $keys, true)) { + $keys[] = $key; + $this->saveKeysRegistry($keys); + } + } + + return $result; + } + + /** + * Read the key registry stored inside Xcache. + * + * @return string[] + */ + private function getKeysRegistry(): array + { + if (!\xcache_isset(self::KEYS_REGISTRY_KEY)) { + return []; + } + + $stored = \xcache_get(self::KEYS_REGISTRY_KEY); + if (!\is_string($stored)) { + return []; + } + + $keys = @\unserialize($stored, ['allowed_classes' => false]); + + return \is_array($keys) ? $keys : []; + } + + /** + * Persist the key registry into Xcache (no TTL so it survives as long as the server allows). + * + * @param string[] $keys + * + * @return void + */ + private function saveKeysRegistry(array $keys): void + { + if (empty($keys)) { + \xcache_unset(self::KEYS_REGISTRY_KEY); + + return; + } + + \xcache_set(self::KEYS_REGISTRY_KEY, \serialize(\array_values($keys))); } } diff --git a/src/voku/cache/Cache.php b/src/voku/cache/Cache.php index aa44884..635c2a0 100644 --- a/src/voku/cache/Cache.php +++ b/src/voku/cache/Cache.php @@ -19,6 +19,12 @@ */ class Cache implements iCache { + /** + * Reserved store key used to persist the keys registry inside the adapter. + * Users should not store cache items under this key. + */ + private const KEYS_INDEX_KEY = '__simple_cache_keys_index__'; + /** * @var array */ @@ -368,6 +374,102 @@ protected function cleanStoreKey(string $str): string return \md5($str); } + /** + * Get the store key used to persist the keys registry in the adapter. + * + * @return string + */ + private function getKeysIndexStoreKey(): string + { + return $this->calculateStoreKey(self::KEYS_INDEX_KEY); + } + + /** + * Read all tracked raw (unprefixed) keys from the keys registry. + * + * @return string[] + */ + private function getKeysFromIndex(): array + { + if (!$this->adapter instanceof iAdapter) { + return []; + } + + $stored = $this->adapter->get($this->getKeysIndexStoreKey()); + if ($stored === null || !\is_string($stored)) { + return []; + } + + // Use PHP's native unserialize so the registry format is independent of + // whatever $this->serializer is configured to use. This prevents + // cross-serializer corruption when a shared backend (static AdapterArray, + // APCu) is accessed by different Cache instances that use different + // serializers (e.g. SerializerDefault in ArrayCacheTest vs + // SerializerIgbinary in CacheChainTest). + $keys = @\unserialize($stored, ['allowed_classes' => false]); + + return \is_array($keys) ? $keys : []; + } + + /** + * Persist a list of raw keys to the keys registry in the adapter. + * + * @param string[] $keys + * + * @return void + */ + private function saveKeysToIndex(array $keys): void + { + if (!$this->adapter instanceof iAdapter) { + return; + } + + $indexKey = $this->getKeysIndexStoreKey(); + + if (empty($keys)) { + $this->adapter->remove($indexKey); + + return; + } + + // Always use PHP's native serialize so the registry format is independent + // of $this->serializer and can be safely read by any Cache instance that + // targets the same backend, regardless of its configured serializer. + $this->adapter->set($indexKey, \serialize(\array_values($keys))); + } + + /** + * Add a raw key to the keys registry (no-op if already present). + * + * @param string $key + * + * @return void + */ + private function addKeyToIndex(string $key): void + { + $keys = $this->getKeysFromIndex(); + if (!\in_array($key, $keys, true)) { + $keys[] = $key; + $this->saveKeysToIndex($keys); + } + } + + /** + * Remove a raw key from the keys registry. + * + * @param string $key + * + * @return void + */ + private function removeKeyFromIndex(string $key): void + { + $keys = $this->getKeysFromIndex(); + $filtered = \array_values(\array_diff($keys, [$key])); + if (\count($filtered) !== \count($keys)) { + $this->saveKeysToIndex($filtered); + } + } + /** * Check if cached-item exists. * @@ -508,7 +610,77 @@ public function removeItem(string $key): bool ); } - return $this->adapter->remove($storeKey); + $result = $this->adapter->remove($storeKey); + + // Always clean the registry, even when the item was already gone (e.g. expired). + $this->removeKeyFromIndex($key); + + return $result; + } + + /** + * Remove all cached-items whose keys match a given regular expression. + * + *

The pattern is matched against the raw (unprefixed) key supplied to setItem(). + * This method works with all adapters because it uses an in-adapter key registry + * maintained by setItem() / removeItem() / removeAll().

+ * + * @param string $pattern A valid PHP regular expression (e.g. '/^imagecache_/'). + * + * @return bool + *

Returns true on success or when no tracked keys match the pattern. + * Returns false only if a matched item that still exists could not be removed.

+ */ + public function removeItems(string $pattern): bool + { + if (!$this->adapter instanceof iAdapter) { + return false; + } + + $rawKeys = $this->getKeysFromIndex(); + if (empty($rawKeys)) { + return true; + } + + $results = []; + $keysToRemove = []; + + foreach ($rawKeys as $rawKey) { + if (\preg_match($pattern, $rawKey) !== 1) { + continue; + } + + $storeKey = $this->calculateStoreKey($rawKey); + + // Remove from static-cache + if ( + !empty(self::$STATIC_CACHE) + && + \array_key_exists($storeKey, self::$STATIC_CACHE) + ) { + unset( + self::$STATIC_CACHE[$storeKey], + self::$STATIC_CACHE_COUNTER[$storeKey], + self::$STATIC_CACHE_EXPIRE[$storeKey] + ); + } + + $removed = $this->adapter->remove($storeKey); + // Treat an already-absent item (e.g. expired) as successfully removed. + if (!$removed && !$this->adapter->exists($storeKey)) { + $removed = true; + } + $results[] = $removed; + $keysToRemove[] = $rawKey; + } + + // Prune matched keys from the registry regardless of removal outcome. + if (!empty($keysToRemove)) { + $remainingKeys = \array_values(\array_diff($rawKeys, $keysToRemove)); + $this->saveKeysToIndex($remainingKeys); + } + + return \in_array(false, $results, true) === false; } /** @@ -549,10 +721,16 @@ public function setItem(string $key, $value, $ttl = 0): bool // always cache the TTL time, maybe we need this later ... self::$STATIC_CACHE_EXPIRE[$storeKey] = ($ttl ? (int) $ttl + \time() : 0); - return $this->adapter->setExpired($storeKey, $serialized, $ttl); + $result = $this->adapter->setExpired($storeKey, $serialized, $ttl); + } else { + $result = $this->adapter->set($storeKey, $serialized); + } + + if ($result) { + $this->addKeyToIndex($key); } - return $this->adapter->set($storeKey, $serialized); + return $result; } /** diff --git a/src/voku/cache/CacheChain.php b/src/voku/cache/CacheChain.php index 7b70a75..c7cd9a2 100644 --- a/src/voku/cache/CacheChain.php +++ b/src/voku/cache/CacheChain.php @@ -145,6 +145,21 @@ public function removeItem(string $key): bool return \in_array(true, $results, true); } + /** + * {@inheritdoc} + */ + public function removeItems(string $pattern): bool + { + // init + $results = []; + + foreach ($this->caches as $cache) { + $results[] = $cache->removeItems($pattern); + } + + return \in_array(true, $results, true); + } + /** * {@inheritdoc} */ diff --git a/src/voku/cache/iAdapter.php b/src/voku/cache/iAdapter.php index 7d6f798..3fd0e54 100644 --- a/src/voku/cache/iAdapter.php +++ b/src/voku/cache/iAdapter.php @@ -71,4 +71,12 @@ public function exists(string $key): bool; * @return bool */ public function installed(): bool; + + /** + * Get all stored keys. + * + * @return string[] + *

Returns an empty array if the adapter does not support listing keys.

+ */ + public function getAllKeys(): array; } diff --git a/src/voku/cache/iCache.php b/src/voku/cache/iCache.php index 6ae918e..ba8add2 100644 --- a/src/voku/cache/iCache.php +++ b/src/voku/cache/iCache.php @@ -51,6 +51,17 @@ public function setItemToDate(string $key, $value, DateTimeInterface $date): boo */ public function removeItem(string $key): bool; + /** + * Remove all items whose keys match a given regular expression. + * + * @param string $pattern A valid PHP regular expression (e.g. '/^imagecache_/'). + * + * @return bool + *

Returns true on success or when no items matched the pattern. + * Returns false if the adapter does not support key listing or a removal failed.

+ */ + public function removeItems(string $pattern): bool; + /** * remove all items * diff --git a/tests/ArrayCacheTest.php b/tests/ArrayCacheTest.php index 4264e15..106e698 100644 --- a/tests/ArrayCacheTest.php +++ b/tests/ArrayCacheTest.php @@ -119,12 +119,17 @@ public function testGetStaticValues() static::assertSame([3, 2, 1], $return); assert($this->cache->getAdapter() instanceof AdapterArray); - static::assertSame([ - 'foo_null' => 'N;', - 'foo' => 'a:3:{i:0;i:3;i:1;i:2;i:2;i:1;}', - 'ao' => 'O:11:"ArrayObject":4:{i:0;i:0;i:1;a:1:{s:3:"arr";s:10:"array data";}i:2;a:1:{s:4:"prop";s:9:"prop data";}i:3;N;}', - 'barfoo' => 'a:3:{i:0;i:3;i:1;i:2;i:2;i:1;}' - ], $this->cache->getAdapter()->getStaticValues()); + $values = $this->cache->getAdapter()->getStaticValues(); + + // User-set values are present with the expected serialized content. + static::assertSame('N;', $values['foo_null']); + static::assertSame('a:3:{i:0;i:3;i:1;i:2;i:2;i:1;}', $values['foo']); + static::assertSame('O:11:"ArrayObject":4:{i:0;i:0;i:1;a:1:{s:3:"arr";s:10:"array data";}i:2;a:1:{s:4:"prop";s:9:"prop data";}i:3;N;}', $values['ao']); + static::assertSame('a:3:{i:0;i:3;i:1;i:2;i:2;i:1;}', $values['barfoo']); + + // The adapter also holds key-registry entries (one per prefix used so far). + static::assertArrayHasKey('__simple_cache_keys_index__', $values); + static::assertArrayHasKey('bar__simple_cache_keys_index__', $values); } public function testGetStaticKeys() @@ -136,7 +141,92 @@ public function testGetStaticKeys() static::assertSame([3, 2, 1], $return); assert($this->cache->getAdapter() instanceof AdapterArray); - static::assertSame(['foo_null', 'foo', 'ao', 'barfoo'], $this->cache->getAdapter()->getStaticKeys()); + $keys = $this->cache->getAdapter()->getStaticKeys(); + + // All user-set keys are present. + static::assertContains('foo_null', $keys); + static::assertContains('foo', $keys); + static::assertContains('ao', $keys); + static::assertContains('barfoo', $keys); + + // The adapter also holds key-registry entries (one per prefix used so far). + static::assertContains('__simple_cache_keys_index__', $keys); + static::assertContains('bar__simple_cache_keys_index__', $keys); + } + + public function testRemoveItems() + { + $return = $this->cache->setItem('imagecache_foo', [1, 2, 3]); + static::assertTrue($return); + + $return = $this->cache->setItem('imagecache_bar', [4, 5, 6]); + static::assertTrue($return); + + $return = $this->cache->setItem('other_item', [7, 8, 9]); + static::assertTrue($return); + + // -- verify items exist + + static::assertSame([1, 2, 3], $this->cache->getItem('imagecache_foo')); + static::assertSame([4, 5, 6], $this->cache->getItem('imagecache_bar')); + static::assertSame([7, 8, 9], $this->cache->getItem('other_item')); + + // -- remove items matching pattern + + $return = $this->cache->removeItems('/^imagecache_/'); + static::assertTrue($return); + + // -- verify matching items are removed + + static::assertNull($this->cache->getItem('imagecache_foo')); + static::assertNull($this->cache->getItem('imagecache_bar')); + + // -- verify non-matching item is still present + + static::assertSame([7, 8, 9], $this->cache->getItem('other_item')); + } + + public function testRemoveItemsWithPrefix() + { + $this->cache->setPrefix('myapp_'); + + $return = $this->cache->setItem('imagecache_foo', [1, 2, 3]); + static::assertTrue($return); + + $return = $this->cache->setItem('imagecache_bar', [4, 5, 6]); + static::assertTrue($return); + + $return = $this->cache->setItem('other_item', [7, 8, 9]); + static::assertTrue($return); + + // -- remove items matching pattern (pattern matches raw key, not prefixed store key) + + $return = $this->cache->removeItems('/^imagecache_/'); + static::assertTrue($return); + + // -- verify matching items are removed + + static::assertNull($this->cache->getItem('imagecache_foo')); + static::assertNull($this->cache->getItem('imagecache_bar')); + + // -- verify non-matching item is still present + + static::assertSame([7, 8, 9], $this->cache->getItem('other_item')); + } + + public function testRemoveItemsNoMatch() + { + $return = $this->cache->setItem('foo', [1, 2, 3]); + static::assertTrue($return); + + // -- no items match, should return true + + $return = $this->cache->removeItems('/^nonexistent_/'); + static::assertTrue($return); + + // -- item is still present + + static::assertSame([1, 2, 3], $this->cache->getItem('foo')); } public function testSetGetCacheWithEndDateTime() diff --git a/tests/CacheAdapterAutoManagerTest.php b/tests/CacheAdapterAutoManagerTest.php new file mode 100644 index 0000000..f7a123c --- /dev/null +++ b/tests/CacheAdapterAutoManagerTest.php @@ -0,0 +1,145 @@ +expectException(InvalidArgumentException::class); + + $manager = new CacheAdapterAutoManager(); + $manager->addAdapter(\stdClass::class); + } + + public function testAddAdapterReturnsFluentInterface() + { + $manager = new CacheAdapterAutoManager(); + $result = $manager->addAdapter(AdapterArray::class); + + static::assertSame($manager, $result); + } + + // ------------------------------------------------------------------------- + // getAdapters / getDefaultsForAutoInit + // ------------------------------------------------------------------------- + + public function testGetAdaptersYieldsRegisteredAdapter() + { + $manager = new CacheAdapterAutoManager(); + $manager->addAdapter(AdapterArray::class); + + $found = []; + foreach ($manager->getAdapters() as $class => $callable) { + $found[$class] = $callable; + } + + static::assertArrayHasKey(AdapterArray::class, $found); + static::assertNull($found[AdapterArray::class]); + } + + public function testGetAdaptersYieldsCallableWhenProvided() + { + $callable = static function () { + return null; + }; + + $manager = new CacheAdapterAutoManager(); + $manager->addAdapter(AdapterArray::class, $callable); + + foreach ($manager->getAdapters() as $class => $fn) { + if ($class === AdapterArray::class) { + static::assertSame($callable, $fn); + } + } + } + + public function testGetDefaultsForAutoInitContainsAdapterArray() + { + $manager = CacheAdapterAutoManager::getDefaultsForAutoInit(); + + $classes = []; + foreach ($manager->getAdapters() as $class => $callable) { + $classes[] = $class; + } + + static::assertContains(AdapterArray::class, $classes); + static::assertGreaterThan(1, \count($classes)); + } + + // ------------------------------------------------------------------------- + // merge + // ------------------------------------------------------------------------- + + public function testMergeAddsAdapterFromOtherManager() + { + $manager1 = new CacheAdapterAutoManager(); + $manager1->addAdapter(AdapterArray::class); + + $manager2 = new CacheAdapterAutoManager(); + $manager2->addAdapter(AdapterFile::class); + + $manager1->merge($manager2); + + $classes = []; + foreach ($manager1->getAdapters() as $class => $callable) { + $classes[] = $class; + } + + static::assertContains(AdapterArray::class, $classes); + static::assertContains(AdapterFile::class, $classes); + } + + public function testMergeUpdatesCallableForExistingAdapterAtNonZeroIndex() + { + // Put AdapterFile at index 0 and AdapterArray at index 1. + // array_search will return 1 (truthy) for AdapterArray, triggering the + // update branch inside merge(). + $manager1 = new CacheAdapterAutoManager(); + $manager1->addAdapter(AdapterFile::class); + $manager1->addAdapter(AdapterArray::class); + + $callable = static function () { + return null; + }; + + $manager2 = new CacheAdapterAutoManager(); + $manager2->addAdapter(AdapterArray::class, $callable); + + $manager1->merge($manager2); + + // The update branch must not duplicate AdapterArray – still 2 adapters. + $classes = []; + foreach ($manager1->getAdapters() as $class => $fn) { + $classes[] = $class; + } + + static::assertCount(2, $classes); + static::assertSame([AdapterFile::class, AdapterArray::class], $classes); + } + + public function testMergeReturnsSelf() + { + $manager1 = new CacheAdapterAutoManager(); + $manager1->addAdapter(AdapterArray::class); + + $manager2 = new CacheAdapterAutoManager(); + $manager2->addAdapter(AdapterFile::class); + + $result = $manager1->merge($manager2); + + static::assertSame($manager1, $result); + } +} diff --git a/tests/CacheChainTest.php b/tests/CacheChainTest.php index d3f08f4..683b3b9 100644 --- a/tests/CacheChainTest.php +++ b/tests/CacheChainTest.php @@ -58,6 +58,52 @@ public function testGetCacheIsReady() static::assertTrue($return); } + public function testGetCaches() + { + $caches = $this->cache->getCaches(); + + static::assertIsArray($caches); + // setUp adds cacheApc (prepend via constructor) then cacheArray (prepend via addCache), + // so there are 2 entries in the chain. + static::assertCount(2, $caches); + } + + public function testAddCacheWithAppendPutsItLast() + { + $extraCache = new Cache( + new \voku\cache\AdapterArray(), + new \voku\cache\SerializerDefault(), + false, + true + ); + $extraCache->setPrefix('chain_append_test_'); + + $before = \count($this->cache->getCaches()); + + $this->cache->addCache($extraCache, false); // false = append, not prepend + + $after = $this->cache->getCaches(); + + static::assertCount($before + 1, $after); + static::assertSame($extraCache, \end($after)); + } + + public function testRemoveItems() + { + $this->cache->removeAll(); + + $this->cache->setItem('chain_pattern_a', [10]); + $this->cache->setItem('chain_pattern_b', [20]); + $this->cache->setItem('chain_keep', [30]); + + $result = $this->cache->removeItems('/^chain_pattern_/'); + static::assertTrue($result); + + static::assertNull($this->cache->getItem('chain_pattern_a')); + static::assertNull($this->cache->getItem('chain_pattern_b')); + static::assertSame([30], $this->cache->getItem('chain_keep')); + } + public function testSetGetItemWithPrefix() { $this->cache->setPrefix('bar'); diff --git a/tests/CachePsr16ArrayTest.php b/tests/CachePsr16ArrayTest.php new file mode 100644 index 0000000..b40470b --- /dev/null +++ b/tests/CachePsr16ArrayTest.php @@ -0,0 +1,146 @@ +cache->set('hello', 'world'); + static::assertTrue($result); + + static::assertTrue($this->cache->has('hello')); + static::assertSame('world', $this->cache->get('hello')); + } + + public function testGetWithDefaultWhenMissing() + { + $result = $this->cache->get('nonexistent_key', 'fallback'); + + static::assertSame('fallback', $result); + } + + public function testGetReturnsNullDefaultWhenMissing() + { + static::assertNull($this->cache->get('still_missing')); + } + + // ------------------------------------------------------------------------- + // setMultiple / getMultiple + // ------------------------------------------------------------------------- + + public function testSetMultipleAndGetMultiple() + { + $result = $this->cache->setMultiple(['alpha' => 1, 'beta' => 2, 'gamma' => 3]); + static::assertTrue($result); + + $items = $this->cache->getMultiple(['alpha', 'beta', 'gamma']); + + static::assertSame(1, $items['alpha']); + static::assertSame(2, $items['beta']); + static::assertSame(3, $items['gamma']); + } + + public function testGetMultipleWithDefaultForMissingKeys() + { + $this->cache->set('exists', 'yes'); + + $items = $this->cache->getMultiple(['exists', 'missing_one', 'missing_two'], 'N/A'); + + static::assertSame('yes', $items['exists']); + static::assertSame('N/A', $items['missing_one']); + static::assertSame('N/A', $items['missing_two']); + } + + // ------------------------------------------------------------------------- + // deleteMultiple + // ------------------------------------------------------------------------- + + public function testDeleteMultipleRemovesMatchingKeysOnly() + { + $this->cache->setMultiple(['del1' => 'a', 'del2' => 'b', 'keep' => 'c']); + + $result = $this->cache->deleteMultiple(['del1', 'del2']); + static::assertTrue($result); + + static::assertNull($this->cache->get('del1')); + static::assertNull($this->cache->get('del2')); + static::assertSame('c', $this->cache->get('keep')); + } + + public function testDeleteMultipleWithEmptyListReturnsTrue() + { + $result = $this->cache->deleteMultiple([]); + + static::assertTrue($result); + } + + // ------------------------------------------------------------------------- + // clear + // ------------------------------------------------------------------------- + + public function testClearRemovesAllItems() + { + $this->cache->set('k1', 'v1'); + $this->cache->set('k2', 'v2'); + + $result = $this->cache->clear(); + static::assertTrue($result); + + static::assertFalse($this->cache->has('k1')); + static::assertFalse($this->cache->has('k2')); + } + + // ------------------------------------------------------------------------- + // delete (single) + // ------------------------------------------------------------------------- + + public function testDeleteRemovesItem() + { + $this->cache->set('bye', 'soon'); + static::assertTrue($this->cache->has('bye')); + + $result = $this->cache->delete('bye'); + static::assertTrue($result); + + static::assertFalse($this->cache->has('bye')); + } + + // ------------------------------------------------------------------------- + // setUp + // ------------------------------------------------------------------------- + + /** + * @before + */ + protected function setUpThanksForNothing() + { + $this->cache = new CachePsr16(new AdapterArray(), new SerializerDefault(), false, true); + + // reset default prefix and wipe adapter state from previous tests + $this->cache->setPrefix('psr16array_test_'); + $this->cache->removeAll(); + $this->cache->setPrefix('psr16array_test_'); + } +} diff --git a/tests/CachePsr16Test.php b/tests/CachePsr16Test.php index 964477e..64b6276 100644 --- a/tests/CachePsr16Test.php +++ b/tests/CachePsr16Test.php @@ -155,6 +155,65 @@ public function testGetMultipleIterableReturnType() static::assertSame('iterable', (string) $method->getReturnType()); } + public function testClear() + { + $this->adapter->expects(static::once()) + ->method('removeAll') + ->willReturn(true); + + $result = $this->cache->clear(); + + static::assertTrue($result); + } + + public function testDeleteWithNonStringKey() + { + $this->expectException(\voku\cache\Exception\InvalidArgumentException::class); + + // @phpstan-ignore-next-line + $this->cache->delete(42); + } + + public function testHasWithNonStringKey() + { + $this->expectException(\voku\cache\Exception\InvalidArgumentException::class); + + // @phpstan-ignore-next-line + $this->cache->has(42); + } + + public function testSetWithNonStringKey() + { + $this->expectException(\voku\cache\Exception\InvalidArgumentException::class); + + // @phpstan-ignore-next-line + $this->cache->set(42, 'value'); + } + + public function testSetMultipleWithNonIterable() + { + $this->expectException(\voku\cache\Exception\InvalidArgumentException::class); + + // @phpstan-ignore-next-line + $this->cache->setMultiple('not_an_array'); + } + + public function testGetMultipleWithNonIterable() + { + $this->expectException(\voku\cache\Exception\InvalidArgumentException::class); + + // @phpstan-ignore-next-line + $this->cache->getMultiple('not_an_array'); + } + + public function testDeleteMultipleWithNonIterable() + { + $this->expectException(\voku\cache\Exception\InvalidArgumentException::class); + + // @phpstan-ignore-next-line + $this->cache->deleteMultiple('not_an_array'); + } + /** * @before */ diff --git a/tests/CacheTest.php b/tests/CacheTest.php index c76de88..b5ad98e 100644 --- a/tests/CacheTest.php +++ b/tests/CacheTest.php @@ -165,6 +165,126 @@ public function testExists() $this->cache->existsItem($key); } + public function testRemoveAll() + { + $this->adapter->expects(static::once()) + ->method('removeAll') + ->willReturn(true); + + $result = $this->cache->removeAll(); + + static::assertTrue($result); + } + + public function testGetAdapter() + { + static::assertSame($this->adapter, $this->cache->getAdapter()); + } + + public function testGetSerializer() + { + static::assertSame($this->serializer, $this->cache->getSerializer()); + } + + public function testGetStaticCacheHitCounterDefault() + { + static::assertSame(10, $this->cache->getStaticCacheHitCounter()); + } + + public function testSetStaticCacheHitCounter() + { + $this->cache->setStaticCacheHitCounter(3); + + static::assertSame(3, $this->cache->getStaticCacheHitCounter()); + } + + public function testGetUsedAdapterClassName() + { + $className = $this->cache->getUsedAdapterClassName(); + + // The mock wraps AdapterApc, so the class name contains it. + static::assertStringContainsString('AdapterApc', $className); + } + + public function testGetUsedSerializerClassName() + { + $className = $this->cache->getUsedSerializerClassName(); + + // The mock wraps SerializerDefault, so the class name contains it. + static::assertStringContainsString('SerializerDefault', $className); + } + + public function testGetAndSetPrefix() + { + $this->cache->setPrefix('myprefix_'); + + static::assertSame('myprefix_', $this->cache->getPrefix()); + } + + public function testUsedAdapterClassNameEmptyWhenNoAdapter() + { + // A disabled cache has no adapter. + $cache = new Cache(null, null, false, false); + + static::assertSame('', $cache->getUsedAdapterClassName()); + } + + public function testUsedSerializerClassNameEmptyWhenNoSerializer() + { + $cache = new Cache(null, null, false, false); + + static::assertSame('', $cache->getUsedSerializerClassName()); + } + + public function testCacheIsNotReadyWhenDisabled() + { + $cache = new Cache(null, null, false, false); + + static::assertFalse($cache->getCacheIsReady()); + } + + public function testSetItemReturnsFalseWhenDisabled() + { + $cache = new Cache(null, null, false, false); + + static::assertFalse($cache->setItem('key', 'value')); + } + + public function testGetItemReturnsNullWhenDisabled() + { + $cache = new Cache(null, null, false, false); + + static::assertNull($cache->getItem('key')); + } + + public function testRemoveItemReturnsFalseWhenDisabled() + { + $cache = new Cache(null, null, false, false); + + static::assertFalse($cache->removeItem('key')); + } + + public function testExistsItemReturnsFalseWhenDisabled() + { + $cache = new Cache(null, null, false, false); + + static::assertFalse($cache->existsItem('key')); + } + + public function testRemoveAllReturnsFalseWhenDisabled() + { + $cache = new Cache(null, null, false, false); + + static::assertFalse($cache->removeAll()); + } + + public function testRemoveItemsReturnsFalseWhenDisabled() + { + $cache = new Cache(null, null, false, false); + + static::assertFalse($cache->removeItems('/^foo/')); + } + /** * @before */ diff --git a/tests/FileCacheTest.php b/tests/FileCacheTest.php index c133b3c..fe5f7c1 100644 --- a/tests/FileCacheTest.php +++ b/tests/FileCacheTest.php @@ -206,6 +206,69 @@ public function testSetGetCacheWithEndDateTimeAndStaticCacheForce() static::assertNull($return); } + public function testRemoveItems() + { + $this->cache->removeAll(); + + $return = $this->cache->setItem('filecache_foo', [1, 2, 3]); + static::assertTrue($return); + + $return = $this->cache->setItem('filecache_bar', [4, 5, 6]); + static::assertTrue($return); + + $return = $this->cache->setItem('other_item', [7, 8, 9]); + static::assertTrue($return); + + // -- verify items exist + + static::assertSame([1, 2, 3], $this->cache->getItem('filecache_foo')); + static::assertSame([4, 5, 6], $this->cache->getItem('filecache_bar')); + static::assertSame([7, 8, 9], $this->cache->getItem('other_item')); + + // -- remove items matching pattern + + $return = $this->cache->removeItems('/^filecache_/'); + static::assertTrue($return); + + // -- verify matching items are removed + + static::assertNull($this->cache->getItem('filecache_foo')); + static::assertNull($this->cache->getItem('filecache_bar')); + + // -- verify non-matching item is still present + + static::assertSame([7, 8, 9], $this->cache->getItem('other_item')); + } + + public function testAdapterGetAllKeys() + { + // Use a dedicated directory so we start with a clean slate. + $dir = \realpath(\sys_get_temp_dir()) . '/simple_php_cache_test_file_keys'; + $adapter = new \voku\cache\AdapterFile($dir); + $adapter->removeAll(); + + // Empty at start. + static::assertSame([], $adapter->getAllKeys()); + + // Keys appear after set(). + $adapter->set('alpha', 'aaa'); + $adapter->set('beta', 'bbb'); + + $keys = $adapter->getAllKeys(); + \sort($keys); + static::assertSame(['alpha', 'beta'], $keys); + + // Key disappears after remove(). + $adapter->remove('alpha'); + $keys = $adapter->getAllKeys(); + static::assertNotContains('alpha', $keys); + static::assertContains('beta', $keys); + + // All keys gone after removeAll(). + $adapter->removeAll(); + static::assertSame([], $adapter->getAllKeys()); + } + public function testGetUsedAdapterClassName() { static::assertSame('voku\cache\AdapterFile', $this->cache->getUsedAdapterClassName()); diff --git a/tests/FileSimpleCacheTest.php b/tests/FileSimpleCacheTest.php index d1dfacb..9380840 100644 --- a/tests/FileSimpleCacheTest.php +++ b/tests/FileSimpleCacheTest.php @@ -206,6 +206,68 @@ public function testSetGetCacheWithEndDateTimeAndStaticCacheForce() static::assertNull($return); } + public function testRemoveItems() + { + $this->cache->removeAll(); + + $return = $this->cache->setItem('filecache_foo', [1, 2, 3]); + static::assertTrue($return); + + $return = $this->cache->setItem('filecache_bar', [4, 5, 6]); + static::assertTrue($return); + + $return = $this->cache->setItem('other_item', [7, 8, 9]); + static::assertTrue($return); + + // -- verify items exist + + static::assertSame([1, 2, 3], $this->cache->getItem('filecache_foo')); + static::assertSame([4, 5, 6], $this->cache->getItem('filecache_bar')); + static::assertSame([7, 8, 9], $this->cache->getItem('other_item')); + + // -- remove items matching pattern + + $return = $this->cache->removeItems('/^filecache_/'); + static::assertTrue($return); + + // -- verify matching items are removed + + static::assertNull($this->cache->getItem('filecache_foo')); + static::assertNull($this->cache->getItem('filecache_bar')); + + // -- verify non-matching item is still present + + static::assertSame([7, 8, 9], $this->cache->getItem('other_item')); + } + + public function testAdapterGetAllKeys() + { + $dir = \realpath(\sys_get_temp_dir()) . '/simple_php_cache_test_filesimple_keys'; + $adapter = new \voku\cache\AdapterFileSimple($dir); + $adapter->removeAll(); + + // Empty at start. + static::assertSame([], $adapter->getAllKeys()); + + // Keys appear after set(). + $adapter->set('alpha', 'aaa'); + $adapter->set('beta', 'bbb'); + + $keys = $adapter->getAllKeys(); + \sort($keys); + static::assertSame(['alpha', 'beta'], $keys); + + // Key disappears after remove(). + $adapter->remove('alpha'); + $keys = $adapter->getAllKeys(); + static::assertNotContains('alpha', $keys); + static::assertContains('beta', $keys); + + // All keys gone after removeAll(). + $adapter->removeAll(); + static::assertSame([], $adapter->getAllKeys()); + } + public function testGetUsedAdapterClassName() { static::assertSame('voku\cache\AdapterFileSimple', $this->cache->getUsedAdapterClassName()); diff --git a/tests/MemcacheCacheTest.php b/tests/MemcacheCacheTest.php index 973a3fc..7e10e78 100644 --- a/tests/MemcacheCacheTest.php +++ b/tests/MemcacheCacheTest.php @@ -96,6 +96,35 @@ public function testSetGetCacheWithEndDateTime() static::assertSame([3, 2, 1], $return); } + public function testAdapterGetAllKeys() + { + // setUp already marks this skipped when Memcache is unavailable. + assert($this->adapter instanceof \voku\cache\AdapterMemcache); + + $this->adapter->removeAll(); + + // Empty at start. + static::assertSame([], $this->adapter->getAllKeys()); + + // Keys appear after set(). + $this->adapter->set('fruit1', 'apple'); + $this->adapter->set('fruit2', 'banana'); + + $keys = $this->adapter->getAllKeys(); + \sort($keys); + static::assertSame(['fruit1', 'fruit2'], $keys); + + // Key disappears after remove(). + $this->adapter->remove('fruit1'); + $keys = $this->adapter->getAllKeys(); + static::assertNotContains('fruit1', $keys); + static::assertContains('fruit2', $keys); + + // All keys gone after removeAll(). + $this->adapter->removeAll(); + static::assertSame([], $this->adapter->getAllKeys()); + } + /** * @before */ diff --git a/tests/OpCacheTest.php b/tests/OpCacheTest.php index 525b1b3..f421739 100644 --- a/tests/OpCacheTest.php +++ b/tests/OpCacheTest.php @@ -194,6 +194,34 @@ public function testSetGetCacheWithEndDateTimeAndStaticCacheForce() static::assertNull($return); } + public function testAdapterGetAllKeys() + { + $dir = \realpath(\sys_get_temp_dir()) . '/simple_php_cache_test_opcache_keys'; + $adapter = new \voku\cache\AdapterOpCache($dir); + $adapter->removeAll(); + + // Empty at start. + static::assertSame([], $adapter->getAllKeys()); + + // Keys appear after set(). + $adapter->set('alpha', 'aaa'); + $adapter->set('beta', 'bbb'); + + $keys = $adapter->getAllKeys(); + \sort($keys); + static::assertSame(['alpha', 'beta'], $keys); + + // Key disappears after remove(). + $adapter->remove('alpha'); + $keys = $adapter->getAllKeys(); + static::assertNotContains('alpha', $keys); + static::assertContains('beta', $keys); + + // All keys gone after removeAll(). + $adapter->removeAll(); + static::assertSame([], $adapter->getAllKeys()); + } + public function testGetUsedAdapterClassName() { static::assertSame('voku\cache\AdapterOpCache', $this->cache->getUsedAdapterClassName());