Skip to content

Commit

Permalink
feature/initial-cache-implementation - refactor to psr/cache implemen…
Browse files Browse the repository at this point in the history
…tation
  • Loading branch information
chr15k committed Jan 14, 2025
1 parent c596eec commit 5665dd3
Show file tree
Hide file tree
Showing 9 changed files with 361 additions and 236 deletions.
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
services:
php:
image: tigitz/phpspellchecker:${PHP_VERSION:-8.4}
user: 1001:1001
build:
context: docker/php
args:
Expand Down
12 changes: 0 additions & 12 deletions src/Cache/CacheInterface.php

This file was deleted.

76 changes: 76 additions & 0 deletions src/Cache/CacheItem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace PhpSpellcheck\Cache;

use DateInterval;
use DateTimeInterface;
use Psr\Cache\CacheItemInterface;

final class CacheItem implements CacheItemInterface

Check failure on line 11 in src/Cache/CacheItem.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Class PhpSpellcheck\Cache\CacheItem implements unknown interface Psr\Cache\CacheItemInterface.
{
public function __construct(
private readonly string $key,
private mixed $value = null,
public ?DateTimeInterface $expiry = null,
private ?bool $isHit = false
) {
}

public function getKey(): string
{
return $this->key;
}

public function get(): mixed
{
return $this->value;
}

public function isHit(): bool
{
return $this->isHit;

Check failure on line 33 in src/Cache/CacheItem.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Method PhpSpellcheck\Cache\CacheItem::isHit() should return bool but returns bool|null.
}

public function set(mixed $value): static
{
$this->value = $value;

return $this;
}

public function expiresAt(?DateTimeInterface $expiration): static
{
$this->expiry = $expiration;

return $this;
}

public function expiresAfter(DateInterval|int|null $time): static
{
if ($time === null) {
$this->expiry = null;

return $this;
}

if (is_int($time)) {
$this->expiry = new \DateTime('@' . (time() + $time));

return $this;
}

$datetime = new \DateTime();
$datetime->add($time);

$this->expiry = $datetime;

return $this;
}

public function setIsHit(bool $hit): void
{
$this->isHit = $hit;
}
}
26 changes: 0 additions & 26 deletions src/Cache/CacheValue.php

This file was deleted.

188 changes: 115 additions & 73 deletions src/Cache/FileCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,147 +4,189 @@

namespace PhpSpellcheck\Cache;

use Psr\Cache\CacheItemInterface;
use Composer\Autoload\ClassLoader;
use PhpSpellcheck\Exception\RuntimeException;
use PhpSpellcheck\Exception\InvalidArgumentException;

final class FileCache implements CacheInterface
final class FileCache implements FileCacheInterface
{
private array $deferred = [];

Check failure on line 14 in src/Cache/FileCache.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Property PhpSpellcheck\Cache\FileCache::$deferred type has no value type specified in iterable type array.

/**
* $namespace - The namespace of the cache (e.g., 'Aspell' creates .phpspellcache.cache/Aspell/*)
* $defaultLifetime - The default lifetime in seconds for cached items (0 = never expires)
* $directory - Optional custom directory path for cache storage
*/
public function __construct(
private readonly string $namespace = '',
private readonly string $namespace = '@',

Check failure on line 22 in src/Cache/FileCache.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Property PhpSpellcheck\Cache\FileCache::$namespace is never read, only written.
private readonly int $defaultLifetime = 0,
private ?string $directory = null,
) {
if ($directory === null) {
$directory = $this->getDefaultDirectory();
}

if (strlen($namespace) > 0) {
$this->validateNamespace($namespace);
$directory .= DIRECTORY_SEPARATOR . $namespace;
} else {
$directory .= DIRECTORY_SEPARATOR . '@';
}
$this->validateNamespace($namespace);

if (!is_dir($directory)) {
mkdir($directory, 0755, true);
$directory .= DIRECTORY_SEPARATOR . $namespace;

if (!is_dir($directory) && !@mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new RuntimeException(sprintf('Directory "%s" could not be created', $directory));
}

$this->directory = $directory .= DIRECTORY_SEPARATOR;
}

public static function create(string $namespace = '', int $defaultLifetime = 0, ?string $directory = null): CacheInterface
{
public static function create(
string $namespace = '@',
int $defaultLifetime = 0,
?string $directory = null
): self {
return new self($namespace, $defaultLifetime, $directory);
}

public function getDefaultDirectory(): string
public function getItem(string $key): CacheItemInterface

Check failure on line 49 in src/Cache/FileCache.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Method PhpSpellcheck\Cache\FileCache::getItem() has invalid return type Psr\Cache\CacheItemInterface.
{
return dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache';
}
$this->validateKey($key);
$filepath = $this->getFilePath($key);

public function get(string $key, mixed $default = null): mixed
{
if (!$this->has($key)) {
return $default;
$item = new CacheItem($key);

if (!file_exists($filepath)) {
return $item;

Check failure on line 57 in src/Cache/FileCache.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Method PhpSpellcheck\Cache\FileCache::getItem() should return Psr\Cache\CacheItemInterface but returns PhpSpellcheck\Cache\CacheItem.
}

return $this->getValueObject($key)?->value;
$handle = fopen($filepath, 'r');
if (flock($handle, LOCK_SH)) { // Shared lock for reading

Check failure on line 61 in src/Cache/FileCache.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Parameter #1 $stream of function flock expects resource, resource|false given.
try {
$data = fread($handle, filesize($filepath));

Check failure on line 63 in src/Cache/FileCache.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Parameter #1 $stream of function fread expects resource, resource|false given.

Check failure on line 63 in src/Cache/FileCache.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Parameter #2 $length of function fread expects int<1, max>, int<0, max>|false given.
$value = unserialize($data);

Check failure on line 64 in src/Cache/FileCache.php

View workflow job for this annotation

GitHub Actions / phpstan (8.2)

Parameter #1 $data of function unserialize expects string, string|false given.

if ($value && (!$value->expiresAt || $value->expiresAt > time())) {
$item->set($value->data);
$item->setIsHit(true);
if ($value->expiresAt) {
$item->expiresAt(new \DateTime('@' . $value->expiresAt));
}
}
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}

return $item;
}

private function getValueObject(string $key): ?CacheValue
public function getItems(array $keys = []): iterable
{
try {
$value = unserialize(\PhpSpellcheck\file_get_contents($this->getFilePath($key)));
return array_map(fn ($key) => $this->getItem($key), $keys);
}

return $value instanceof CacheValue ? $value : null;
} catch (\Throwable) {
return null;
}
public function hasItem(string $key): bool
{
return $this->getItem($key)->isHit();
}

public function has(string $key): bool
public function clear(): bool
{
if (!file_exists($this->getFilePath($key))) {
$this->deferred = [];
$files = glob($this->directory.'*');

if ($files === false || empty($files)) {
return false;
}

$object = $this->getValueObject($key);
$result = true;
foreach ($files as $file) {
$result = unlink($file) && $result;
}

return $object !== null && $object->isValid();
return $result;
}

public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool
public function deleteItem(string $key): bool
{
$this->validateKey($key);
unset($this->deferred[$key]);

$ttl ??= $this->defaultLifetime;

if ($ttl instanceof \DateInterval) {
$expiresAt = (new \DateTime())->add($ttl)->getTimestamp();
} else {
$expiresAt = $ttl > 0 ? time() + $ttl : null;
if (!file_exists($this->getFilePath($key))) {
return true;
}

$data = new CacheValue($value, $expiresAt);

return (bool) \PhpSpellcheck\file_put_contents($this->getFilePath($key), $data->serialize(), LOCK_EX);
return unlink($this->getFilePath($key));
}

public function delete(string $key): bool
public function deleteItems(array $keys): bool
{
if (!$this->has($key)) {
return false;
$result = true;
foreach ($keys as $key) {
$result = $this->deleteItem($key) && $result;
}

return unlink($this->getFilePath($key));
return $result;
}

public function clear(): bool
public function save(CacheItemInterface $item): bool
{
$files = glob($this->directory.'*');
$this->validateKey($item->getKey());

if ($files === false || empty($files)) {
$expiresAt = null;
if ($item->expiry) {
$expiresAt = $item->expiry->getTimestamp();
} elseif ($this->defaultLifetime > 0) {
$expiresAt = time() + $this->defaultLifetime;
}

$value = (object) [
'data' => $item->get(),
'expiresAt' => $expiresAt,
];

$serialized = serialize($value);
$filepath = $this->getFilePath($item->getKey());

$handle = fopen($filepath, 'w');
if (!$handle) {
return false;
}

$result = true;
foreach ($files as $file) {
$result = unlink($file);
$success = false;
if (flock($handle, LOCK_EX)) { // Exclusive lock for writing
try {
$success = fwrite($handle, $serialized) !== false;
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}

return $result;
return $success;
}

public function getMultiple(iterable $keys, mixed $default = null): iterable
public function saveDeferred(CacheItemInterface $item): bool
{
foreach ($keys as $key) {
yield $key => $this->get($key, $default);
}
$this->validateKey($item->getKey());
$this->deferred[$item->getKey()] = $item;

return true;
}

/**
* @param iterable<mixed> $values
*/
public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool
public function commit(): bool
{
$result = true;
foreach ($values as $key => $value) {
if (is_string($key)) {
$result = $this->set($key, $value, $ttl) && $result;
}
$success = true;
foreach ($this->deferred as $item) {
$success = $this->save($item) && $success;
}
$this->deferred = [];

return $result;
return $success;
}

public function deleteMultiple(iterable $keys): bool
private function getDefaultDirectory(): string
{
$result = true;
foreach ($keys as $key) {
$result = $this->delete($key) && $result;
}

return $result;
return dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache';
}

public function getFilePath(string $key): string
Expand Down
Loading

0 comments on commit 5665dd3

Please sign in to comment.