From 83beca30a9e0f28c2043ebef436e7d96799ac5e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Gamez?= Date: Sun, 3 Dec 2023 23:47:40 +0100 Subject: [PATCH] Add the implementation --- CHANGELOG.md | 4 +- README.md | 48 +++++++++--- composer.json | 26 ++++--- phpunit.dist.xml | 1 - src/CacheItem.php | 81 ++++++++++++++++++++ src/CacheKey.php | 25 +++++++ src/InMemoryCache.php | 108 +++++++++++++++++++++++++++ src/InvalidArgument.php | 11 +++ src/Placeholder.php | 20 ----- tests/CacheItemTest.php | 122 ++++++++++++++++++++++++++++++ tests/CacheKeyTest.php | 49 ++++++++++++ tests/InMemoryCacheTest.php | 144 ++++++++++++++++++++++++++++++++++++ tests/PlaceholderTest.php | 31 -------- 13 files changed, 597 insertions(+), 73 deletions(-) create mode 100644 src/CacheItem.php create mode 100644 src/CacheKey.php create mode 100644 src/InMemoryCache.php create mode 100644 src/InvalidArgument.php delete mode 100644 src/Placeholder.php create mode 100644 tests/CacheItemTest.php create mode 100644 tests/CacheKeyTest.php create mode 100644 tests/InMemoryCacheTest.php delete mode 100644 tests/PlaceholderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 95cc752..9caf1b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,6 @@ ## [Unreleased] diff --git a/README.md b/README.md index 20e04b0..09621a0 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,49 @@ -# PSR-6 Array Cache +# PSR-6 In-Memory Cache -A PSR-6 Array Cache +A [PSR-6](https://www.php-fig.org/psr/psr-6/) In-Memory cache that can be used as a default implementation and in tests. - +[![Current version](https://img.shields.io/packagist/v/beste/in-memory-cache-php.svg?logo=composer)](https://packagist.org/packages/beste/in-memory-cache-php) +[![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/beste/in-memory-cache-php)](https://packagist.org/packages/beste/in-memory-cache-php) +[![Monthly Downloads](https://img.shields.io/packagist/dm/beste/in-memory-cache-php.svg)](https://packagist.org/packages/beste/in-memory-cache-php/stats) +[![Total Downloads](https://img.shields.io/packagist/dt/beste/in-memory-cache-php.svg)](https://packagist.org/packages/beste/in-memory-cache-php/stats) +[![Tests](https://github.com/beste/in-memory-cache-php/actions/workflows/tests.yml/badge.svg)](https://github.com/beste/in-memory-cache-php/actions/workflows/tests.yml) ## Installation +In order to use this cache implementation, you also need to install a [PSR-20](https://www.php-fig.org/psr/psr-20/) [Clock Implementation](https://packagist.org/providers/psr/clock-implementation), +for example, the [`beste/clock`](https://packagist.org/packages/beste/clock). + ```shell -composer require beste/array-cache +composer require beste/in-memory-cache beste/clock +``` + +## Usage + +```php +use Beste\Cache\InMemoryCache; +use Beste\Clock\SystemClock; + +$clock = SystemClock::create(); +$cache = new InMemoryCache($clock); + +$item = $cache->getItem('key'); + +assert($item->isHit() === false); +assert($item->get() === null); + +$item->set('value'); +$cache->save($item); + +// Later... + +$item = $cache->getItem('key'); + +assert($item->isHit() === true); +assert($item->get() === 'value'); ``` +The test suite + ## Running tests ```shell diff --git a/composer.json b/composer.json index 5d80edd..a1622ec 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,8 @@ { - "name": "beste/array-cache", - "description": "A PSR-6 Array Cache", - "license": "ISC", + "name": "beste/in-memory-cache", + "description": "A PSR-6 In-Memory cache that can be used as a fallback implementation and/or in tests.", + "keywords": ["cache", "psr-6", "beste"], + "license": "MIT", "type": "library", "authors": [ { @@ -10,26 +11,33 @@ } ], "require": { - "php": "~8.2.0 || ~8.3.0" + "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "psr/cache": "^2.0 || ^3.0", + "psr/clock": "^1.0", + "psr/clock-implementation": "^1.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.40.2", + "beste/clock": "^3.0", + "friendsofphp/php-cs-fixer": "^3.41.0", "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan": "^1.10.47", + "phpstan/phpstan": "^1.10.48", "phpstan/phpstan-deprecation-rules": "^1.1.4", "phpstan/phpstan-phpunit": "^1.3.15", "phpstan/phpstan-strict-rules": "^1.5.2", - "phpunit/phpunit": "^10.5.1", + "phpunit/phpunit": "^10.5.2", "symfony/var-dumper": "^6.4.0" }, + "provide": { + "psr/cache-implementation": "2.0 || 3.0" + }, "autoload": { "psr-4": { - "Beste\\Psr\\Cache\\": "src" + "Beste\\Cache\\": "src" } }, "autoload-dev": { "psr-4": { - "Beste\\Psr\\Cache\\Tests\\": "tests" + "Beste\\Cache\\Tests\\": "tests" } }, "config": { diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 8259eb5..68a85c6 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -2,7 +2,6 @@ diff --git a/src/CacheItem.php b/src/CacheItem.php new file mode 100644 index 0000000..8feb77a --- /dev/null +++ b/src/CacheItem.php @@ -0,0 +1,81 @@ +value = null; + $this->expiresAt = null; + $this->isHit = false; + } + + public function getKey(): string + { + return $this->key->toString(); + } + + public function get(): mixed + { + if ($this->isHit()) { + return $this->value; + } + + return null; + } + + public function isHit(): bool + { + if ($this->isHit === false) { + return false; + } + + if ($this->expiresAt === null) { + return true; + } + + return $this->clock->now()->getTimestamp() < $this->expiresAt->getTimestamp(); + } + + public function set(mixed $value): static + { + $this->isHit = true; + $this->value = $value; + + return $this; + } + + public function expiresAt(?\DateTimeInterface $expiration): static + { + $this->expiresAt = $expiration; + + return $this; + } + + public function expiresAfter(\DateInterval|int|null $time): static + { + if ($time === null) { + $this->expiresAt = null; + return $this; + } + + if (is_int($time)) { + $time = new \DateInterval("PT{$time}S"); + } + + $this->expiresAt = $this->clock->now()->add($time); + + return $this; + } +} diff --git a/src/CacheKey.php b/src/CacheKey.php new file mode 100644 index 0000000..0de2d63 --- /dev/null +++ b/src/CacheKey.php @@ -0,0 +1,25 @@ +value; + } +} diff --git a/src/InMemoryCache.php b/src/InMemoryCache.php new file mode 100644 index 0000000..1035093 --- /dev/null +++ b/src/InMemoryCache.php @@ -0,0 +1,108 @@ + */ + private array $items; + /** @var array */ + private array $deferredItems; + + public function __construct(private readonly ClockInterface $clock) + { + $this->items = []; + $this->deferredItems = []; + } + + public function getItem(string $key): CacheItemInterface + { + $key = CacheKey::fromString($key); + + $item = $this->items[$key->toString()] ?? null; + + if ($item === null) { + return new CacheItem($key, $this->clock); + } + + return clone $item; + } + + /** + * @return iterable + */ + public function getItems(array $keys = []): iterable + { + if ($keys === []) { + return []; + } + + $items = []; + + foreach ($keys as $key) { + $items[$key] = $this->getItem($key); + } + + return $items; + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function clear(): bool + { + $this->items = []; + $this->deferredItems = []; + + return true; + } + + public function deleteItem(string $key): bool + { + $key = CacheKey::fromString($key); + + unset($this->items[$key->toString()]); + + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + $this->deleteItem($key); + } + + return true; + } + + public function save(CacheItemInterface $item): bool + { + $this->items[$item->getKey()] = $item; + + return true; + } + + public function saveDeferred(CacheItemInterface $item): bool + { + $this->deferredItems[$item->getKey()] = $item; + + return true; + } + + public function commit(): bool + { + foreach ($this->deferredItems as $item) { + $this->save($item); + } + + $this->deferredItems = []; + + return true; + } +} diff --git a/src/InvalidArgument.php b/src/InvalidArgument.php new file mode 100644 index 0000000..c3ac09d --- /dev/null +++ b/src/InvalidArgument.php @@ -0,0 +1,11 @@ +prefix = $prefix; - } - - public function echo(string $value): string - { - return $this->prefix.$value; - } -} diff --git a/tests/CacheItemTest.php b/tests/CacheItemTest.php new file mode 100644 index 0000000..94f30da --- /dev/null +++ b/tests/CacheItemTest.php @@ -0,0 +1,122 @@ +key = 'key'; + $this->clock = FrozenClock::fromUTC(); + $this->cacheItem = new CacheItem(CacheKey::fromString($this->key), $this->clock); + } + + public function testItHasAKey(): void + { + self::assertSame($this->key, $this->cacheItem->getKey()); + } + + public function testItInitiallyHasNoValue(): void + { + self::assertNull($this->cacheItem->get()); + } + + public function testItInitiallyIsNotAHit(): void + { + self::assertFalse($this->cacheItem->isHit()); + } + + public function testItHasAValueWhenSetWithOne(): void + { + $this->cacheItem->set('value'); + + self::assertSame('value', $this->cacheItem->get()); + } + + public function testItBecomesHitWhenSetWithAValue(): void + { + $this->cacheItem->set('value'); + + self::assertTrue($this->cacheItem->isHit()); + } + + public function testItHasAValueAsLongAsItIsNotExpiredAtAGivenTime(): void + { + $this->cacheItem->set('value'); + $this->cacheItem->expiresAt($this->clock->now()->modify('+1 minute')); + + self::assertSame('value', $this->cacheItem->get()); + } + + public function testItHasNoValueWhenItIsExpiredAtAGivenTime(): void + { + $this->cacheItem->set('value'); + $this->cacheItem->expiresAt($this->clock->now()->modify('-1 minute')); + + self::assertNull($this->cacheItem->get()); + } + + public function testItIsAHitAsLongAsItIsNotExpiredAtAGivenTime(): void + { + $this->cacheItem->set('value'); + $this->cacheItem->expiresAt($this->clock->now()->modify('+1 minute')); + + self::assertTrue($this->cacheItem->isHit()); + } + + public function testItIsAMissWhenItIsExpiredAtAGivenTime(): void + { + $this->cacheItem->set('value'); + $this->cacheItem->expiresAt($this->clock->now()->modify('-1 minute')); + + self::assertFalse($this->cacheItem->isHit()); + } + + public function testTheExpirationCanBeGivenInSeconds(): void + { + $this->cacheItem->set('value'); + assert($this->cacheItem->isHit() === true); + + $this->cacheItem->expiresAfter(60); + $this->clock->setTo($this->clock->now()->modify('+61 seconds')); + + self::assertFalse($this->cacheItem->isHit()); + } + + public function testTheExpirationCanBeGivenAsADateInterval(): void + { + $this->cacheItem->set('value'); + assert($this->cacheItem->isHit() === true); + + $this->cacheItem->expiresAfter(new \DateInterval('PT60S')); + $this->clock->setTo($this->clock->now()->modify('+61 seconds')); + + self::assertFalse($this->cacheItem->isHit()); + } + + public function testTheExpirationCanBeUnset(): void + { + $this->cacheItem->set('value'); + $this->cacheItem->expiresAfter(60); + $this->clock->setTo($this->clock->now()->modify('+61 seconds')); + assert($this->cacheItem->isHit() === false); + + $this->cacheItem->expiresAfter(null); + + self::assertTrue($this->cacheItem->isHit()); + + + } +} diff --git a/tests/CacheKeyTest.php b/tests/CacheKeyTest.php new file mode 100644 index 0000000..4b756c1 --- /dev/null +++ b/tests/CacheKeyTest.php @@ -0,0 +1,49 @@ +> + */ + public static function validValues(): array + { + return [ + 'single char' => ['x'], + '64 chars' => [str_repeat('x', 64)], + ]; + } + + /** + * @return array> + */ + public static function invalidValues(): array + { + return [ + 'empty string' => [''], + 'invalid character' => ['\\'], + 'too long' => [str_repeat('x', 65)], + ]; + } +} diff --git a/tests/InMemoryCacheTest.php b/tests/InMemoryCacheTest.php new file mode 100644 index 0000000..d7f3609 --- /dev/null +++ b/tests/InMemoryCacheTest.php @@ -0,0 +1,144 @@ +clock = FrozenClock::fromUTC(); + $this->pool = new InMemoryCache($this->clock); + } + + public function testItReturnsANewItem(): void + { + $item = $this->pool->getItem('item'); + + self::assertFalse($item->isHit()); + self::assertNull($item->get()); + } + + public function testItUsesTheProvidedClock(): void + { + $item = $this->pool->getItem('item'); + $item->set('value'); + $item->expiresAfter(new \DateInterval('PT2H')); + $this->pool->save($item); + + $this->clock->setTo($this->clock->now()->add(new \DateInterval('PT1H'))); + self::assertTrue($this->pool->getItem('item')->isHit()); + + $this->clock->setTo($this->clock->now()->add(new \DateInterval('PT2H'))); + self::assertFalse($this->pool->getItem('item')->isHit()); + } + + public function testItSavesAnItem(): void + { + $item = $this->pool->getItem('item'); + + $item->set('value'); + $this->pool->save($item); + + self::assertTrue($this->pool->getItem('item')->isHit()); + self::assertSame('value', $this->pool->getItem('item')->get()); + } + + public function testItHasAnItem(): void + { + self::assertFalse($this->pool->hasItem('key')); + + $item = $this->pool->getItem('key'); + $item->set('value'); + $this->pool->save($item); + + self::assertTrue($this->pool->hasItem('key')); + } + + public function testItCommitsDeferredItems(): void + { + $item = $this->pool->getItem('item'); + + $item->set('value'); + + $this->pool->saveDeferred($item); + + self::assertFalse($this->pool->getItem('item')->isHit()); + + $this->pool->commit(); + + self::assertTrue($this->pool->getItem('item')->isHit()); + } + + public function testItCanBeCleared(): void + { + $this->pool->save($this->pool->getItem('key')->set('value')); + + self::assertTrue($this->pool->getItem('key')->isHit()); + + $this->pool->clear(); + + self::assertFalse($this->pool->getItem('key')->isHit()); + } + + public function testItReturnsMultipleItems(): void + { + $this->pool->save($this->pool->getItem('first')->set('value')); + $this->pool->save($this->pool->getItem('third')->set('value')); + + $items = $this->pool->getItems(['first', 'second', 'third']); + + self::assertCount(3, $items); + self::assertIsArray($items); + + self::assertArrayHasKey('first', $items); + self::assertTrue($items['first']->isHit()); + + self::assertArrayHasKey('second', $items); + self::assertFalse($items['second']->isHit()); + + self::assertArrayHasKey('third', $items); + self::assertTrue($items['third']->isHit()); + } + + public function testItReturnsNoItemsWhenNoKeysAreGiven(): void + { + $this->pool->save($this->pool->getItem('key')->set('value')); + + self::assertEmpty($this->pool->getItems()); + } + + public function testItDeletesAnItem(): void + { + $this->pool->save($this->pool->getItem('key')->set('value')); + + self::assertTrue($this->pool->hasItem('key')); + + $this->pool->deleteItem('key'); + + self::assertFalse($this->pool->hasItem('key')); + } + + public function testItDeletesMultipleItems(): void + { + $this->pool->save($this->pool->getItem('first')->set('value')); + $this->pool->save($this->pool->getItem('second')->set('value')); + $this->pool->save($this->pool->getItem('third')->set('value')); + + $this->pool->deleteItems(['first', 'third', 'fourth']); + + self::assertFalse($this->pool->hasItem('first')); + self::assertTrue($this->pool->hasItem('second')); + self::assertFalse($this->pool->hasItem('third')); + self::assertFalse($this->pool->hasItem('fourth')); + } +} diff --git a/tests/PlaceholderTest.php b/tests/PlaceholderTest.php deleted file mode 100644 index 72696bd..0000000 --- a/tests/PlaceholderTest.php +++ /dev/null @@ -1,31 +0,0 @@ -placeholder = new Placeholder('Jérôme Gamez says: '); - } - - /** - * @test - */ - public function it_echoes_a_value(): void - { - self::assertSame('Jérôme Gamez says: Hello', $this->placeholder->echo('Hello')); - } -}