Skip to content

feature: initial PSR-6 cache implementation #61

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5c3b13a
feature/initial-cache-implementation - initial implementation with ba…
chr15k Jan 9, 2025
76af19d
feature/initial-cache-implementation - update gitignore
chr15k Jan 9, 2025
394ed7e
feature/initial-cache-implementation - composer.json updated
chr15k Jan 9, 2025
69bfc8a
feature/initial-cache-implementation - streamline cache directory res…
chr15k Jan 9, 2025
a2fb471
feature/initial-cache-implementation - decouple adapter args from abs…
chr15k Jan 9, 2025
d64bed3
feature/initial-cache-implementation - get phpstan passing
chr15k Jan 9, 2025
2e3b173
feature/initial-cache-implementation - initial aspell cache implement…
chr15k Jan 10, 2025
8554e45
feature/initial-cache-implementation - refactor cache methods and ins…
chr15k Jan 10, 2025
dfcadff
Merge branch 'master' into feature/initial-cache-implementation
chr15k Jan 10, 2025
eb2d58d
feature/initial-cache-implementation - phpcs fixer
chr15k Jan 10, 2025
3942885
Merge branch 'feature/initial-cache-implementation' of github.com:chr…
chr15k Jan 10, 2025
9e6f7b3
feature/initial-cache-implementation - fix phpcs deprecation warning
chr15k Jan 10, 2025
7e4e17d
feature/initial-cache-implementation - fix phpcs deprecation warning
chr15k Jan 10, 2025
0b87946
feature/initial-cache-implementation - revert phpcs native_function_i…
chr15k Jan 10, 2025
fc88d4c
feature/initial-cache-implementation - re-ran phpcs fixer
chr15k Jan 10, 2025
f1b8ffa
feature/initial-cache-implementation - replace symfony/cache with psr…
chr15k Jan 11, 2025
a7bc62a
feature/initial-cache-implementation - rebase master
chr15k Jan 11, 2025
3ea7c67
feature/initial-cache-implementation - update Safe method namespace
chr15k Jan 11, 2025
4d31033
feature/initial-cache-implementation - minor changes
chr15k Jan 12, 2025
c596eec
feature/initial-cache-implementation - minor changes
chr15k Jan 12, 2025
5665dd3
feature/initial-cache-implementation - refactor to psr/cache implemen…
chr15k Jan 14, 2025
9a64c36
feature/initial-cache-implementation - refactor to psr/cache implemen…
chr15k Jan 14, 2025
51ab8f3
feature/initial-cache-implementation - phpstan fixes
chr15k Jan 15, 2025
bd2f4c4
feature/initial-cache-implementation - phpstan fixes
chr15k Jan 15, 2025
f21ccb5
feature/initial-cache-implementation - remove readonly class for php …
chr15k Jan 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ build
composer.lock
vendor
.idea
.vscode
.php-cs-fixer.cache
.phpunit.result.cache
cache
.phpspellcheck.cache
composer/
.DS_Store
.aider*
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"php": "^8.2",
"nyholm/psr7": "^1.3",
"psr/http-client": "^1.0",
"psr/cache": "^3.0",
"symfony/process": "^6.4 | ^7",
"webmozart/assert": "^1.11"
},
Expand All @@ -50,7 +51,10 @@
"psr-4": {
"PhpSpellcheck\\": "src"
},
"files": [ "src/Text/functions.php" , "src/Utils/php-functions.php" ]
"files": [
"src/Text/functions.php",
"src/Utils/php-functions.php"
]
},
"autoload-dev": {
"psr-4": {
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ services:
PHP_VERSION: ${PHP_VERSION:-8.4}
volumes:
- .:/usr/src/myapp
- ./cache:/root/composer/cache
- ./composer/cache:/root/composer/cache
environment:
- LANG=en_US.UTF-8
- COMPOSER_CACHE_DIR=/root/composer/cache
Expand Down
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
{
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;
}

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;
}
}
226 changes: 226 additions & 0 deletions src/Cache/FileCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
<?php

declare(strict_types=1);

namespace PhpSpellcheck\Cache;

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

final class FileCache implements FileCacheInterface
{
/**
* @var array<string, CacheItemInterface>
*/
private array $deferred = [];

/**
* $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 int $defaultLifetime = 0,
private ?string $directory = null,
) {
if ($directory === null) {
$directory = $this->getDefaultDirectory();
}

$this->validateNamespace();

$directory .= DIRECTORY_SEPARATOR . $namespace;

if (!is_dir($directory) && !@mkdir($directory, 0o777, 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
): self {
return new self($namespace, $defaultLifetime, $directory);
}

public function getItem(string $key): CacheItemInterface
{
$this->validateKey($key);
$filepath = $this->getFilePath($key);

$item = new CacheItem($key);

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

$data = \PhpSpellcheck\file_get_contents($filepath);

if ($data === '') {
return $item;
}

$value = unserialize($data);

if (!\is_object($value)
|| !property_exists($value, 'data')
|| !property_exists($value, 'expiresAt')
) {
return $item;
}

if ($value->expiresAt !== 0
&& $value->expiresAt !== null
&& $value->expiresAt <= time()
) {
unlink($filepath);

return $item;
}

$item->set($value->data)->setIsHit(true);

if (\is_int($value->expiresAt) && $value->expiresAt > 0) {
$item->expiresAt(new \DateTime('@' . $value->expiresAt));
}

return $item;
}

/**
* @param array<string> $keys
*
* @return iterable<CacheItemInterface>
*/
public function getItems(array $keys = []): iterable
{
return array_map(fn ($key): CacheItemInterface => $this->getItem($key), $keys);
}

public function hasItem(string $key): bool
{
return $this->getItem($key)->isHit();
}

public function clear(): bool
{
$this->deferred = [];
$files = glob($this->directory.'*');

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

$result = true;
foreach ($files as $file) {
$result = unlink($file) && $result;
}

return $result;
}

public function deleteItem(string $key): bool
{
$this->validateKey($key);
unset($this->deferred[$key]);

if (!file_exists($this->getFilePath($key))) {
return true;
}

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

public function deleteItems(array $keys): bool
{
$result = true;
foreach ($keys as $key) {
$result = $this->deleteItem($key) && $result;
}

return $result;
}

public function save(CacheItemInterface $item): bool
{
$this->validateKey($item->getKey());

if (!property_exists($item, 'expiry')) {
throw new InvalidArgumentException('CacheItem expiry property is required');
}

$expiresAt = match(true) {
$item->expiry instanceof \DateTimeInterface => $item->expiry->getTimestamp(),
$this->defaultLifetime > 0 => time() + $this->defaultLifetime,
default => null
};

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

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

try {
return (bool) \PhpSpellcheck\file_put_contents($filepath, $serialized, LOCK_EX);
} catch (\Exception $e) {
return false;
}
}

public function saveDeferred(CacheItemInterface $item): bool
{
$this->validateKey($item->getKey());
$this->deferred[$item->getKey()] = $item;

return true;
}

public function commit(): bool
{
$success = true;
foreach ($this->deferred as $item) {
$success = $this->save($item) && $success;
}
$this->deferred = [];

return $success;
}

public function getFilePath(string $key): string
{
return $this->directory . $key;
}

private function getDefaultDirectory(): string
{
return \dirname(array_keys(ClassLoader::getRegisteredLoaders())[0]).'/.phpspellcheck.cache';
}

private function validateNamespace(): void
{
if (\PhpSpellcheck\preg_match('#[^-+_.A-Za-z0-9]#', $this->namespace, $match) === 1) {
throw new InvalidArgumentException(\sprintf('Namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0]));
}
}

private function validateKey(string $key): void
{
if (\PhpSpellcheck\preg_match('/^[a-zA-Z0-9_\.]+$/', $key) === 0) {
throw new InvalidArgumentException(
\sprintf(
'Invalid cache key "%s". A cache key can only contain letters (a-z, A-Z), numbers (0-9), underscores (_), and periods (.).',
$key
)
);
}
}
}
12 changes: 12 additions & 0 deletions src/Cache/FileCacheInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace PhpSpellcheck\Cache;

use Psr\Cache\CacheItemPoolInterface;

interface FileCacheInterface extends CacheItemPoolInterface
{
public function getFilePath(string $key): string;
}
Loading
Loading