commit 01a13b7f2bef38181472d10393305efc74830037 Author: Mèir Noordermeer Date: Wed Feb 28 15:01:45 2018 +0100 Initial commit cache diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..efcaef4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.idea/ +composer.lock +vendor/ +tmp/ +.vs/ +.vscode/ \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..29df977 --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "iqparts/cache", + "type": "library", + "require": { + "predis/predis": "^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^6.3", + "phpstan/phpstan": "^0.9.1" + }, + "autoload": { + "psr-4": { + "IQParts\\Cache\\": ["src"] + } + }, + "autoload-dev": { + "psr-4": { + "IQParts\\CacheTest\\": ["test"] + } + }, + "authors": [ + { + "name": "Mèir Noordermeer", + "email": "meirnoordermeer@me.com" + } + ] +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..a21d4e1 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + ./test/Unit + + + + ./src + + + \ No newline at end of file diff --git a/src/Adapter/CacheAdapterInterface.php b/src/Adapter/CacheAdapterInterface.php new file mode 100644 index 0000000..079d923 --- /dev/null +++ b/src/Adapter/CacheAdapterInterface.php @@ -0,0 +1,39 @@ +indexLocation = $directory . '/' . md5($directory) . 'index'; + $this->ttlLocation = $directory . '/' . md5($directory) . 'ttl'; + $this->directory = $directory; + $this->serializer = $serializer; + $this->directorySeparator = $directorySeperator; + $this->chmod = $chmod; + $this->ttl = $ttl; + + if (file_exists($this->indexLocation)) { + $this->index = json_decode(file_get_contents($this->indexLocation), true); + } else { + $this->index = []; + } + + if (file_exists($this->ttlLocation)) { + $this->timeToLive = json_decode(file_get_contents($this->ttlLocation), true); + } else { + $this->timeToLive = []; + } + } + + /** + * @param string $key + * @return mixed + */ + public function get(string $key) + { + $file = $this->getFilename($key); + if ($this->exists($file)) { + if (isset($this->timeToLive[$key])) { + if ($this->ttl($key) > 0) { + return $this->serializer->deserialize(file_get_contents($file)); + } + } else { + return $this->serializer->deserialize(file_get_contents($file)); + } + + } + return null; + } + + /** + * @param string $key + * @param $value + * @param int|null $ttl + * @return void + */ + public function set(string $key, $value, int $ttl = null) + { + $file = $this->getFilename($key); + file_put_contents($file, $this->serializer->serialize($value)); + if ($this->chmod !== null) { + chmod($file, $this->chmod); + } + if ($ttl !== null) { + $this->timeToLive[$key] = $ttl; + } else if ($this->ttl !== null) { + $this->timeToLive[$key] = $this->ttl; + } + + $this->index[$key] = $file; + $this->saveIndex(); + } + + /** + * @param string $key + */ + public function delete(string $key) + { + if (strpos($key, '*') !== false) { + $this->deleteGlob($key); + $this->saveIndex(); + } else { + $file = $this->getFilename($key); + if ($this->exists($file)) { + unlink($file); + } + unset($this->index[$key]); + unset($this->timeToLive[$key]); + $this->saveIndex(); + } + } + + /** + * @param string $key + * @return mixed + */ + public function keys($key = '*') + { + $matches = []; + foreach ($this->index as $name => $index) { + if (fnmatch($key, $name)) { + $matches[] = $name; + } + } + return $matches; + } + + /** + * @param $key + * @return int + */ + public function ttl($key): int + { + $file = $this->getFilename($key); + + if (isset($this->timeToLive[$key])) { + return max(filemtime($file) + $this->timeToLive[$key] - time(), 0); + } + + return self::NO_TTL; + } + + /** + * @param $pattern + */ + private function deleteGlob($pattern) + { + foreach ($this->index as $key => $value) { + if (fnmatch($pattern, $key)) { + $this->delete($key); + } + } + } + + /** + * @param $file + * @return bool + */ + private function exists(string $file) + { + return file_exists($file); + } + + /** + * @param $key + * @return string + */ + private function getFilename(string $key) + { + list($directory, $file) = $this->getDirectoryAndFile($key); + return $directory . '/' . md5($file); + } + + /** + * @param string $key + * @return array + */ + private function getDirectoryAndFile(string $key) + { + $directory = $this->directory; + if ($this->directorySeparator !== null) { + while (($position = strpos($key, $this->directorySeparator)) !== false) { + $dirName = md5(substr($key, 0, $position)); + $directory = $directory . '/' . $dirName; + $this->createSubDirectoryIfNotExists($directory); + $key = substr($key, $position + 1); + } + } + return [$directory, $key]; + } + + /** + * @param $directory + */ + private function createSubDirectoryIfNotExists($directory) + { + if (file_exists($directory) === false) { + mkdir($directory); + if ($this->chmod !== null) { + chmod($directory, $this->chmod); + } + } + } + + private function saveIndex() + { + file_put_contents($this->indexLocation, json_encode($this->index)); + file_put_contents($this->ttlLocation, json_encode($this->timeToLive)); + } +} \ No newline at end of file diff --git a/src/Adapter/MemoryAdapter.php b/src/Adapter/MemoryAdapter.php new file mode 100644 index 0000000..490a2a8 --- /dev/null +++ b/src/Adapter/MemoryAdapter.php @@ -0,0 +1,91 @@ +data[$key])) { + return null; + } + + if (!isset($this->timeToLive[$key])) { + return $this->data[$key]; + } + + if ($this->ttl($key) > 0) { + return $this->data[$key]; + } + + unset($this->timeToLive[$key]); + unset($this->data[$key]); + return null; + } + + /** + * @param string $key + * @param $value + * @param int|null $ttl + * @return void + */ + public function set(string $key, $value, int $ttl = null) + { + $this->data[$key] = $value; + if ($ttl !== null) { + $this->timeToLive[$key] = time() + $ttl; + } + } + + /** + * @param string $key + * @return mixed + */ + public function delete(string $key) + { + unset($this->data[$key]); + unset($this->timeToLive[$key]); + return 1; + } + + /** + * @param string $key + * @return mixed + */ + public function keys($key = '*') + { + $matches = []; + foreach ($this->data as $name => $value) { + if (fnmatch($key, $name)) { + $matches[] = $name; + } + } + return $matches; + } + + /** + * @param $key + * @return int + */ + public function ttl($key): int + { + if (isset($this->timeToLive[$key])) { + return max($this->timeToLive[$key] - time(), 0); + } + return self::NO_TTL; + } +} \ No newline at end of file diff --git a/src/Adapter/NamespaceAdapter.php b/src/Adapter/NamespaceAdapter.php new file mode 100644 index 0000000..2f00af7 --- /dev/null +++ b/src/Adapter/NamespaceAdapter.php @@ -0,0 +1,87 @@ +namespace = $namespace . ':'; + $this->cacheAdapter = $cacheAdapter; + } + + /** + * @param string $key + * @return mixed + */ + public function get(string $key) + { + return $this->cacheAdapter->get($this->namespace . $key); + } + + /** + * @param string $key + * @param $value + * @param int|null $ttl + * @return void + */ + public function set(string $key, $value, int $ttl = null): void + { + $this->cacheAdapter->set($this->namespace . $key, $value, $ttl); + } + + /** + * @param string $key + * @return void + */ + public function delete(string $key) + { + $this->cacheAdapter->delete($this->namespace . $key); + } + + /** + * @param string $key + * @return array + */ + public function keys($key = '*') + { + if (substr($key, -1) !== '*') { + $key = $key . '*'; + } + + $keys = $this->cacheAdapter->keys($this->namespace . $key); + $allowed = []; + $len = strlen($this->namespace); + + foreach ($keys as $key) { + if (substr($key, 0, $len) === $this->namespace) { + $allowed[] = substr($key, $len); + } + } + + return $allowed; + } + + /** + * @param $key + * @return int + */ + public function ttl($key): int + { + return $this->cacheAdapter->ttl($this->namespace . $key); + } +} \ No newline at end of file diff --git a/src/Adapter/NullAdapter.php b/src/Adapter/NullAdapter.php new file mode 100644 index 0000000..fdc7503 --- /dev/null +++ b/src/Adapter/NullAdapter.php @@ -0,0 +1,52 @@ +client = $client; + $this->serializer = $serializer; + $this->defaultTtl = $defaultTtl; + } + + /** + * @param string $key + * @return mixed + */ + public function get(string $key) + { + return $this->serializer->deserialize($this->client->get($key)); + } + + /** + * @param string $key + * @param $value + * @param int|null $ttl TTL in seconds + */ + public function set(string $key, $value, int $ttl = null) + { + if ($ttl !== null && $ttl !== 0) { + $this->client->setex($key, $ttl ?? $this->defaultTtl, $this->serializer->serialize($value)); + } else { + $this->client->set($key, $this->serializer->serialize($value)); + } + } + + /** + * @param string $key + * @return integer + */ + public function delete(string $key): int + { + return $this->client->del([$key]); + } + + /** + * @param string $key + * @return mixed + */ + public function keys($key = '*') + { + return $this->client->keys($key); + } + + /** + * @param $key + * @return int + */ + public function ttl($key): int + { + return $this->client->ttl($key); + } +} \ No newline at end of file diff --git a/src/Serializer/JsonSerializer.php b/src/Serializer/JsonSerializer.php new file mode 100644 index 0000000..d9cc997 --- /dev/null +++ b/src/Serializer/JsonSerializer.php @@ -0,0 +1,20 @@ +getTmpDirectory(); + $adapter = new FilesystemAdapter( + $location, + new JsonSerializer(), + 0777 + ); + + + $adapter->set('a', 'b'); + $this->assertEquals('b', $adapter->get('a')); + + $adapter->delete('a'); + $this->assertEquals(null, $adapter->get('a')); + + $adapter->set('ttl', 'b', 200); + $this->assertTrue($adapter->ttl('ttl') > 0); + $this->assertTrue($adapter->ttl('b') === $adapter::NO_TTL); + + $adapter->set('a-keys', 'a'); + $adapter->set('b-keys', 'a'); + $this->assertEquals(['a-keys'], $adapter->keys('a*')); + $adapter->delete('b'); + $adapter->delete('a-keys'); + $adapter->delete('b-keys'); + } + + public function testTtl() + { + $location = $this->getTmpDirectory(); + $adapter = new FilesystemAdapter( + $location, + new JsonSerializer(), + 0777 + ); + + $adapter->set('a', 'b', 10); + + $this->assertEquals('b', $adapter->get('a')); + $this->assertTrue($adapter->ttl('a') > 0); + + $adapter->set('b', 'a', -10); + $this->assertNull($adapter->get('b')); + + $adapter->delete('b'); + $adapter->delete('a'); + } + + public function testDefaultTtl() + { + $location = $this->getTmpDirectory(); + $adapter = new FilesystemAdapter( + $location, + new JsonSerializer(), + 0777, + 10 + ); + + $adapter->set('a', 'b'); + $this->assertTrue($adapter->ttl('a') > 0); + $adapter->delete('a'); + } + + public function testDelete() + { + $location = $this->getTmpDirectory(); + $adapter = new FilesystemAdapter( + $location, + new JsonSerializer(), + 0777 + ); + + $adapter->set('del-a', 'a'); + $adapter->set('del-b', 'b'); + $adapter->delete('del*'); + $this->assertNull($adapter->get('del-a')); + $this->assertNull($adapter->get('del-b')); + } + + public function testSubFolder() + { + $location = $this->getTmpDirectory(); + $adapter = new FilesystemAdapter( + $location, + new JsonSerializer(), + 0777 + ); + + $adapter->set('a/b', 'a'); + $this->assertEquals('a', $adapter->get('a/b')); + $adapter->delete('a/b'); + } + + public function testSubFolderWithSeperator() + { + $location = $this->getTmpDirectory(); + $adapter = new FilesystemAdapter( + $location, + new JsonSerializer(), + 0777, + null, + '/' + ); + + $adapter->set('a/b', 'a'); + $this->assertEquals('a', $adapter->get('a/b')); + $adapter->delete('a/b'); + } +} \ No newline at end of file diff --git a/test/Unit/Adapter/MemoryAdapterTest.php b/test/Unit/Adapter/MemoryAdapterTest.php new file mode 100644 index 0000000..ff1420e --- /dev/null +++ b/test/Unit/Adapter/MemoryAdapterTest.php @@ -0,0 +1,42 @@ +assertEquals(null, $adapter->get('a')); + + $adapter->set('a', 'b'); + $this->assertEquals('b', $adapter->get('a')); + + $adapter->delete('a'); + $this->assertEquals(null, $adapter->get('a')); + + $adapter->set('a-keys', 'a'); + $adapter->set('b-keys', 'a'); + $this->assertEquals(['a-keys'], $adapter->keys('a*')); + + $adapter->set('a', 'b', 200); + $this->assertTrue($adapter->ttl('a') > 0); + $this->assertTrue($adapter->ttl('c') === MemoryAdapter::NO_TTL); + } + + public function testTtl() + { + $adapter = new MemoryAdapter(); + $adapter->set('a', 'b', 10); + + $this->assertEquals('b', $adapter->get('a')); + $this->assertTrue($adapter->ttl('a') > 0); + + $adapter->set('b', 'a', -10); + $this->assertNull($adapter->get('b')); + } +} \ No newline at end of file diff --git a/test/Unit/Adapter/NamespaceAdapterTest.php b/test/Unit/Adapter/NamespaceAdapterTest.php new file mode 100644 index 0000000..69aa800 --- /dev/null +++ b/test/Unit/Adapter/NamespaceAdapterTest.php @@ -0,0 +1,61 @@ +adapter = new MemoryAdapter(); + } + + public function testGet() + { + $adapter = new NamespaceAdapter('test', $this->adapter); + $adapter->set('mykey', 'a'); + $this->assertEquals('test:mykey', $this->adapter->keys()[0]); + $this->assertEquals('a', $adapter->get('mykey')); + } + + public function testSet() + { + $adapter = new NamespaceAdapter('test', $this->adapter); + $adapter->set('mykey', null); + $this->assertEquals('test:mykey', $this->adapter->keys()[0]); + } + + public function testDelete() + { + $adapter = new NamespaceAdapter('test', $this->adapter); + $adapter->delete('mykey'); + $this->assertEquals(null, $this->adapter->get('mykey')); + } + + public function testKeys() + { + $adapter = new NamespaceAdapter('test', $this->adapter); + $adapter->set('a-keys', 'a'); + $adapter->set('b-keys', 'b'); + $this->assertEquals(['a-keys'], $adapter->keys('a-*')); + $this->assertEquals(['a-keys'], $adapter->keys('a-')); + } + + public function testTtl() + { + $adapter = new NamespaceAdapter('test', $this->adapter); + $adapter->set('a', 'b', 10); + $this->assertTrue($adapter->ttl('a') > 0); + } + +} \ No newline at end of file diff --git a/test/Unit/Adapter/NullAdapterTest.php b/test/Unit/Adapter/NullAdapterTest.php new file mode 100644 index 0000000..2265372 --- /dev/null +++ b/test/Unit/Adapter/NullAdapterTest.php @@ -0,0 +1,19 @@ +set('a', 'b'); + $this->assertEquals(null, $adapter->get('a')); + $adapter->delete('a'); + $this->assertEquals([], $adapter->keys()); + $this->assertEquals(0, $adapter->ttl('a')); + } +} \ No newline at end of file diff --git a/test/Unit/Adapter/PredisAdapterTest.php b/test/Unit/Adapter/PredisAdapterTest.php new file mode 100644 index 0000000..c8d5727 --- /dev/null +++ b/test/Unit/Adapter/PredisAdapterTest.php @@ -0,0 +1,114 @@ +createMock(ClientInterface::class); + $client + ->expects($this->at(0)) + ->method('__call') + ->with('get', ['a']) + ->willReturn(null); + + /** @var ClientInterface $client */ + $cache = new PredisAdapter($client, new JsonSerializer()); + $this->assertNull($cache->get('a')); + } + + public function testSet() + { + $client = $this->createMock(ClientInterface::class); + $client + ->expects($this->at(0)) + ->method('__call') + ->with('set', ['a', 'b']); + $client + ->expects($this->at(1)) + ->method('__call') + ->with('get', ['a']) + ->willReturn('b'); + + /** @var ClientInterface $client */ + $cache = new PredisAdapter($client, new JsonSerializer()); + $cache->set('a', 'b'); + $this->assertEquals('b', $cache->get('a')); + } + + public function testDelete() + { + $client = $this->createMock(ClientInterface::class); + $client + ->expects($this->at(0)) + ->method('__call') + ->with('set', ['a', 'b']); + $client + ->expects($this->at(1)) + ->method('__call') + ->with('del', [['a']]) + ->willReturn(1); + $client + ->expects($this->at(2)) + ->method('__call') + ->with('get', ['a']) + ->willReturn(null); + + /** @var ClientInterface $client */ + $cache = new PredisAdapter($client, new JsonSerializer()); + $cache->set('a', 'b'); + $cache->delete('a'); + $this->assertNull($cache->get('a')); + } + + public function testTtl() + { + $client = $this->createMock(ClientInterface::class); + $client + ->expects($this->at(0)) + ->method('__call') + ->with('setex', ['a', 200, 'b']); + $client + ->expects($this->at(1)) + ->method('__call') + ->with('ttl', ['a']) + ->willReturn(200); + + /** @var ClientInterface $client */ + $cache = new PredisAdapter($client, new JsonSerializer()); + $cache->set('a', 'b', 200); + $this->assertEquals(200, $cache->ttl('a')); + } + + public function testKeys() + { + $client = $this->createMock(ClientInterface::class); + $client + ->expects($this->at(0)) + ->method('__call') + ->with('set', ['a-keys', '1']); + $client + ->expects($this->at(1)) + ->method('__call') + ->with('set', ['b-keys', '1']); + $client + ->expects($this->at(2)) + ->method('__call') + ->with('keys', ['a*']) + ->willReturn(['a-keys']); + + /** @var ClientInterface $client */ + $cache = new PredisAdapter($client, new JsonSerializer()); + $cache->set('a-keys', '1'); + $cache->set('b-keys', '1'); + $this->assertEquals(['a-keys'], $cache->keys('a*')); + + } +} \ No newline at end of file diff --git a/test/Unit/Serializer/JsonSerializerTest.php b/test/Unit/Serializer/JsonSerializerTest.php new file mode 100644 index 0000000..3b3c32c --- /dev/null +++ b/test/Unit/Serializer/JsonSerializerTest.php @@ -0,0 +1,22 @@ + '1', 1 => '2', 2 => '3']; + + $this->assertTrue(is_string($serializer->serialize($array))); + $this->assertEquals($string, $serializer->serialize($string)); + + $this->assertEquals($array, $serializer->deserialize($serializer->serialize($array))); + } + +} \ No newline at end of file diff --git a/test/Unit/Serializer/NoSerializerTest.php b/test/Unit/Serializer/NoSerializerTest.php new file mode 100644 index 0000000..b34d583 --- /dev/null +++ b/test/Unit/Serializer/NoSerializerTest.php @@ -0,0 +1,22 @@ +assertEquals('a', $serializer->serialize('a')); + } + + public function testDeserialize() + { + $serializer = new NoSerializer(); + $this->assertEquals('a', $serializer->deserialize('a')); + } +} \ No newline at end of file