Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
92ea72c
commit f8b7e8b
Showing
7 changed files
with
298 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
<?php | ||
|
||
namespace Phikl\Cache; | ||
|
||
use Psr\SimpleCache\CacheInterface; | ||
|
||
final class MemcachedCache implements CacheInterface | ||
{ | ||
private \Memcached $memcached; | ||
|
||
/** | ||
* @param MemcachedServer|array<MemcachedServer> $servers | ||
*/ | ||
public function __construct(MemcachedServer|array $servers) | ||
{ | ||
if (!\extension_loaded('memcached')) { | ||
throw new \RuntimeException('Memcached extension is not loaded'); | ||
} | ||
|
||
$servers = \is_array($servers) ? $servers : [$servers]; | ||
|
||
$this->memcached = new \Memcached('phikl'); | ||
foreach ($servers as $server) { | ||
$this->memcached->addServer($server->host, $server->port); | ||
} | ||
} | ||
|
||
/** | ||
* @param non-empty-string $key | ||
*/ | ||
public function get(string $key, mixed $default = null): Entry|null | ||
{ | ||
if ($default !== null && !$default instanceof Entry) { | ||
throw new \InvalidArgumentException('Default value must be null or an instance of Entry'); | ||
} | ||
|
||
$entry = $this->memcached->get($key); | ||
if ($this->memcached->getResultCode() === \Memcached::RES_NOTFOUND) { | ||
return $default; | ||
} | ||
|
||
$entry = @unserialize($entry); | ||
if ($entry === false) { | ||
return $default; | ||
} | ||
|
||
return $entry; | ||
} | ||
|
||
public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool | ||
{ | ||
if (!$value instanceof Entry) { | ||
return false; | ||
} | ||
|
||
return $this->memcached->set( | ||
$key, | ||
serialize($value), | ||
$ttl instanceof \DateInterval ? (int) ($ttl->format('U')) - \time() : ($ttl ?? 0) | ||
); | ||
} | ||
|
||
public function delete(string $key): bool | ||
{ | ||
return $this->memcached->delete($key); | ||
} | ||
|
||
/** | ||
* Caution, this method will clear the entire cache, not just the cache for this application. | ||
*/ | ||
public function clear(): bool | ||
{ | ||
return $this->memcached->flush(); | ||
} | ||
|
||
/** | ||
* @param iterable<non-empty-string> $keys | ||
* | ||
* @return array<non-empty-string, Entry|null> | ||
*/ | ||
public function getMultiple(iterable $keys, mixed $default = null): array | ||
{ | ||
$entries = []; | ||
foreach ($keys as $key) { | ||
$entries[$key] = $this->get($key, $default); | ||
} | ||
|
||
return $entries; | ||
} | ||
|
||
/** | ||
* @param iterable<string, Entry> $values | ||
*/ | ||
public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool | ||
{ | ||
foreach ($values as $key => $value) { | ||
if (!$this->set($key, $value, $ttl)) { | ||
return false; | ||
} | ||
} | ||
|
||
return true; | ||
} | ||
|
||
/** | ||
* @param iterable<string> $keys | ||
*/ | ||
public function deleteMultiple(iterable $keys): bool | ||
{ | ||
$success = true; | ||
foreach ($keys as $key) { | ||
if (!$this->delete($key)) { | ||
$success = false; | ||
} | ||
} | ||
|
||
return $success; | ||
} | ||
|
||
public function has(string $key): bool | ||
{ | ||
return $this->memcached->get($key) !== false; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
<?php | ||
|
||
namespace Phikl\Cache; | ||
|
||
final readonly class MemcachedServer | ||
{ | ||
public function __construct( | ||
public string $host, | ||
public int $port, | ||
) { | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
<?php | ||
|
||
namespace Phikl\Tests\Cache; | ||
|
||
use Phikl\Cache\Entry; | ||
use Phikl\Cache\MemcachedCache; | ||
use Phikl\Cache\MemcachedServer; | ||
use PHPUnit\Framework\Attributes\CoversClass; | ||
use PHPUnit\Framework\Attributes\RequiresPhpExtension; | ||
use PHPUnit\Framework\TestCase; | ||
|
||
#[RequiresPhpExtension('memcached')] | ||
#[CoversClass(MemcachedCache::class)] | ||
class MemcachedCacheTest extends TestCase | ||
{ | ||
private function createMemcachedCache(): MemcachedCache | ||
{ | ||
return new MemcachedCache(new MemcachedServer('localhost', 11211)); | ||
} | ||
|
||
public function testGetWithDefaultOtherThanEntryInstance(): void | ||
{ | ||
$cache = $this->createMemcachedCache(); | ||
|
||
$this->expectException(\InvalidArgumentException::class); | ||
$this->expectExceptionMessage('Default value must be null or an instance of Entry'); | ||
|
||
$cache->get('key', 'invalid'); | ||
} | ||
|
||
public function testGetReturnsDefaultIfKeyDoesNotExist(): void | ||
{ | ||
$cache = $this->createMemcachedCache(); | ||
|
||
$entry = new Entry('content', 'hash', 0); | ||
|
||
$this->assertNull($cache->get('nonexistent')); | ||
$this->assertSame($entry, $cache->get('nonexistent', $entry)); | ||
$this->assertFalse($cache->has('nonexistent')); | ||
} | ||
|
||
public function testGetOnValidSetEntry(): void | ||
{ | ||
$cache = $this->createMemcachedCache(); | ||
|
||
$entry = new Entry('content', 'hash', $time = \time()); | ||
|
||
$this->assertTrue($cache->set('key', $entry)); | ||
|
||
$entry = $cache->get('key'); | ||
$this->assertInstanceOf(Entry::class, $entry); | ||
$this->assertSame('content', $entry->content); | ||
$this->assertSame('hash', $entry->hash); | ||
$this->assertSame($time, $entry->timestamp); | ||
} | ||
|
||
public function testSetReturnsFalseOnInvalidEntry(): void | ||
{ | ||
$cache = $this->createMemcachedCache(); | ||
|
||
$this->assertFalse($cache->set('key', 'invalid')); | ||
} | ||
|
||
public function testDeleteEntry(): void | ||
{ | ||
$cache = $this->createMemcachedCache(); | ||
|
||
$entry = new Entry('content', 'hash', 0); | ||
$cache->set('key', $entry); | ||
|
||
$this->assertTrue($cache->delete('key')); | ||
$this->assertNull($cache->get('key')); | ||
} | ||
|
||
public function testClear(): void | ||
{ | ||
$cache = $this->createMemcachedCache(); | ||
|
||
$entry = new Entry('content', 'hash', 0); | ||
$cache->set('key', $entry); | ||
|
||
$this->assertTrue($cache->clear()); | ||
$this->assertNull($cache->get('key')); | ||
} | ||
|
||
public function testGetSetMultiple(): void | ||
{ | ||
$cache = $this->createMemcachedCache(); | ||
|
||
$entry1 = new Entry('content1', 'hash1', 0); | ||
$entry2 = new Entry('content2', 'hash2', 0); | ||
$entry3 = new Entry('content3', 'hash3', 0); | ||
|
||
$cache->setMultiple([ | ||
'key1' => $entry1, | ||
'key2' => $entry2, | ||
'key3' => $entry3, | ||
]); | ||
|
||
$entries = $cache->getMultiple(['key1', 'key2', 'key3']); | ||
|
||
$this->assertArrayHasKey('key1', $entries); | ||
$this->assertArrayHasKey('key2', $entries); | ||
$this->assertArrayHasKey('key3', $entries); | ||
|
||
$this->assertInstanceOf(Entry::class, $entries['key1']); | ||
$this->assertSame('content1', $entries['key1']->content); | ||
$this->assertSame('hash1', $entries['key1']->hash); | ||
|
||
$this->assertInstanceOf(Entry::class, $entries['key2']); | ||
$this->assertSame('content2', $entries['key2']->content); | ||
$this->assertSame('hash2', $entries['key2']->hash); | ||
|
||
$this->assertInstanceOf(Entry::class, $entries['key3']); | ||
$this->assertSame('content3', $entries['key3']->content); | ||
$this->assertSame('hash3', $entries['key3']->hash); | ||
} | ||
|
||
public function testDeleteMultiple(): void | ||
{ | ||
$cache = $this->createMemcachedCache(); | ||
|
||
$entry1 = new Entry('content1', 'hash1', 0); | ||
$entry2 = new Entry('content2', 'hash2', 0); | ||
$entry3 = new Entry('content3', 'hash3', 0); | ||
|
||
$cache->setMultiple([ | ||
'key1' => $entry1, | ||
'key2' => $entry2, | ||
'key3' => $entry3, | ||
]); | ||
|
||
$this->assertTrue($cache->deleteMultiple(['key1', 'key2'])); | ||
$this->assertNull($cache->get('key1')); | ||
$this->assertNull($cache->get('key2')); | ||
$this->assertNotNull($cache->get('key3')); | ||
} | ||
|
||
public function testHas(): void | ||
{ | ||
$cache = $this->createMemcachedCache(); | ||
|
||
$entry = new Entry('content', 'hash', 0); | ||
$cache->set('key', $entry); | ||
|
||
$this->assertTrue($cache->has('key')); | ||
$this->assertFalse($cache->has('invalid')); | ||
} | ||
} |