Skip to content

Commit

Permalink
feat: add FileStorage for cache driver (#386)
Browse files Browse the repository at this point in the history
* feat: add `FileStorage` for cache driver

* resolve review from @anggermpd
  • Loading branch information
SonyPradana authored Sep 17, 2024
1 parent 55c4f1f commit c34f832
Show file tree
Hide file tree
Showing 3 changed files with 355 additions and 0 deletions.
240 changes: 240 additions & 0 deletions src/System/Cache/Storage/FileStorage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<?php

declare(strict_types=1);

namespace System\Cache\Storage;

use System\Cache\CacheInterface;

class FileStorage implements CacheInterface
{
public function __construct(
private string $path,
private int $defaultTTL = 3_600,
) {
if (false === is_dir($this->path)) {
mkdir($this->path, 0777, true);
}
}

/**
* Get info of storage.
*
* @return array<string, array{value: mixed, timestamp?: int, mtime?: float}>
*/
public function getInfo(string $key): array
{
$filePath = $this->makePath($key);

if (false === file_exists($filePath)) {
return [];
}

$data = file_get_contents($filePath);

if (false === $data) {
return [];
}

return unserialize($data);
}

public function get(string $key, mixed $default = null): mixed
{
$filePath = $this->makePath($key);

if (false === file_exists($filePath)) {
return $default;
}

$data = file_get_contents($filePath);

if ($data === false) {
return $default;
}

$cacheData = unserialize($data);

if (time() >= $cacheData['timestamp']) {
$this->delete($key);

return $default;
}

return $cacheData['value'];
}

public function set(string $key, mixed $value, int|\DateInterval|null $ttl = null): bool
{
$filePath = $this->makePath($key);
$directory = dirname($filePath);

if (false === is_dir($directory)) {
mkdir($directory, 0777, true);
}

$cacheData = [
'value' => $value,
'timestamp' => $this->calculateExpirationTimestamp($ttl),
'mtime' => $this->createMtime(),
];

$serializedData = serialize($cacheData);

return file_put_contents($filePath, $serializedData, LOCK_EX) !== false;
}

public function delete(string $key): bool
{
$filePath = $this->makePath($key);

if (file_exists($filePath)) {
return unlink($filePath);
}

return false;
}

public function clear(): bool
{
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->path, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);

foreach ($files as $fileinfo) {
$action = $fileinfo->isDir() ? 'rmdir' : 'unlink';
$action($fileinfo->getRealPath());
}

return true;
}

public function getMultiple(iterable $keys, mixed $default = null): iterable
{
$result = [];

foreach ($keys as $key) {
$result[$key] = $this->get($key, $default);
}

return $result;
}

public function setMultiple(iterable $values, int|\DateInterval|null $ttl = null): bool
{
$state = null;

foreach ($values as $key => $value) {
$result = $this->set($key, $value, $ttl);
$state = is_null($state) ? $result : $result && $state;
}

return $state ?: false;
}

public function deleteMultiple(iterable $keys): bool
{
$state = null;

foreach ($keys as $key) {
$result = $this->delete($key);
$state = is_null($state) ? $result : $result && $state;
}

return $state ?: false;
}

public function has(string $key): bool
{
return file_exists($this->makePath($key));
}

public function increment(string $key, int $value): int
{
if (false === $this->has($key)) {
$this->set($key, $value, 0);

return $value;
}

$info = $this->getInfo($key);

$ori = $info['value'] ?? 0;
$ttl = $info['timestamp'] ?? 0;

if (false === is_int($ori)) {
throw new \InvalidArgumentException('Value incremnet must be interger.');
}

$result = (int) ($ori + $value);

$this->set($key, $result, $ttl);

return $result;
}

public function decrement(string $key, int $value): int
{
return $this->increment($key, $value * -1);
}

public function remember(string $key, int|\DateInterval|null $ttl = null, \Closure $callback): mixed
{
$value = $this->get($key);

if (null !== $value) {
return $value;
}

$this->set($key, $value = $callback(), $ttl);

return $value;
}

/**
* Generate the full file path for the given cache key.
*/
protected function makePath(string $key): string
{
$hash = sha1($key);
$parts = array_slice(str_split($hash, 2), 0, 2);

return $this->path . '/' . implode('/', $parts) . '/' . $hash;
}

private function calculateExpirationTimestamp(int|\DateInterval|\DateTimeInterface|null $ttl): int
{
if ($ttl instanceof \DateInterval) {
return (new \DateTimeImmutable())->add($ttl)->getTimestamp();
}

if ($ttl instanceof \DateTimeInterface) {
return $ttl->getTimestamp();
}

$ttl ??= $this->defaultTTL;

return time() + $ttl;
}

/**
* Calculate the microtime based on the current time and microtime.
*/
private function createMtime(): float
{
$currentTime = time();
$microtime = microtime(true);

$fractionalPart = $microtime - $currentTime;

if ($fractionalPart >= 1) {
$currentTime += (int) $fractionalPart;
$fractionalPart -= (int) $fractionalPart;
}

$mtime = $currentTime + $fractionalPart;

return round($mtime, 3);
}
}
113 changes: 113 additions & 0 deletions tests/Cache/Storage/FileStorageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

namespace System\Text\Cache\Storage;

use PHPUnit\Framework\TestCase;
use System\Cache\Storage\FileStorage;

class FileStorageTest extends TestCase
{
protected FileStorage $storage;

protected function setUp(): void
{
$this->storage = new FileStorage(__DIR__ . '/cache');
}

public function testSetAndGet(): void
{
$this->assertTrue($this->storage->set('key1', 'value1'));
$this->assertEquals('value1', $this->storage->get('key1'));
}

public function testGetWithDefault(): void
{
$this->assertEquals('default', $this->storage->get('non_existing_key', 'default'));
}

public function testSetWithTTL(): void
{
$this->markTestSkipped('sleep is not allowed');
$this->assertTrue($this->storage->set('key2', 'value2', 1));
sleep(2);
$this->assertNull($this->storage->get('key2'));
}

public function testDelete(): void
{
$this->storage->set('key3', 'value3');
$this->assertTrue($this->storage->delete('key3'));
$this->assertFalse($this->storage->has('key3'));
}

public function testDeleteNonExistingKey(): void
{
$this->assertFalse($this->storage->delete('non_existing_key'));
}

public function testClear(): void
{
$this->storage->set('key4', 'value4');
$this->assertTrue($this->storage->clear());
$this->assertFalse($this->storage->has('key4'));
}

public function testGetMultiple(): void
{
$this->storage->set('key5', 'value5');
$this->storage->set('key6', 'value6');
$result = $this->storage->getMultiple(['key5', 'key6', 'non_existing_key'], 'default');
$this->assertEquals(['key5' => 'value5', 'key6' => 'value6', 'non_existing_key' => 'default'], $result);
}

// public function testSetMultiple(): void
// {
// $this->assertFalse($this->storage->setMultiple(['key7' => 'value7', 'key8' => 'value8']));
// $this->assertEquals('value7', $this->storage->get('key7'));
// $this->assertEquals('value8', $this->storage->get('key8'));
// }

public function testDeleteMultiple(): void
{
$this->storage->set('key9', 'value9');
$this->storage->set('key10', 'value10');
$this->assertTrue($this->storage->deleteMultiple(['key9', 'key10']));
$this->assertFalse($this->storage->has('key9'));
$this->assertFalse($this->storage->has('key10'));
}

public function testHas(): void
{
$this->storage->set('key11', 'value11');
$this->assertTrue($this->storage->has('key11'));
$this->assertFalse($this->storage->has('non_existing_key'));
}

public function testIncrement(): void
{
$this->assertEquals(10, $this->storage->increment('key12', 10));
$this->assertEquals(20, $this->storage->increment('key12', 10));
}

public function testDecrement(): void
{
$this->storage->set('key13', 20);
$this->assertEquals(10, $this->storage->decrement('key13', 10));
}

public function testGetInfo(): void
{
$this->storage->set('key14', 'value14');
$info = $this->storage->getInfo('key14');
$this->assertArrayHasKey('value', $info);
$this->assertEquals('value14', $info['value']);
}

public function testRemember(): void
{
$value = $this->storage->remember('key1', 1, fn (): string => 'value1');
$this->assertEquals('value1', $value);
}
}
2 changes: 2 additions & 0 deletions tests/Cache/Storage/cache/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore

0 comments on commit c34f832

Please sign in to comment.