diff -Nru reactphp-cache-0.4.2/CHANGELOG.md reactphp-cache-0.5.0/CHANGELOG.md --- reactphp-cache-0.4.2/CHANGELOG.md 2017-12-20 16:47:13.000000000 +0000 +++ reactphp-cache-0.5.0/CHANGELOG.md 2018-06-25 12:52:40.000000000 +0000 @@ -1,5 +1,19 @@ # Changelog +## 0.5.0 (2018-06-25) + +* Improve documentation by describing what is expected of a class implementing `CacheInterface`. + (#21, #22, #23, #27 by @WyriHaximus) + +* Implemented (optional) Least Recently Used (LRU) cache algorithm for `ArrayCache`. + (#26 by @clue) + +* Added support for cache expiration (TTL). + (#29 by @clue and @WyriHaximus) + +* Renamed `remove` to `delete` making it more in line with `PSR-16`. + (#30 by @clue) + ## 0.4.2 (2017-12-20) * Improve documentation with usage and installation instructions diff -Nru reactphp-cache-0.4.2/debian/changelog reactphp-cache-0.5.0/debian/changelog --- reactphp-cache-0.4.2/debian/changelog 2018-05-18 19:07:18.000000000 +0000 +++ reactphp-cache-0.5.0/debian/changelog 2018-12-02 12:59:33.000000000 +0000 @@ -1,3 +1,10 @@ +reactphp-cache (0.5.0-1) unstable; urgency=medium + + * New upstream version. + * Bump Standards-Version (no changes needed). + + -- Dominik George Sun, 02 Dec 2018 13:59:33 +0100 + reactphp-cache (0.4.2-2) unstable; urgency=medium * Fix typo in my name in d/copyright. diff -Nru reactphp-cache-0.4.2/debian/control reactphp-cache-0.5.0/debian/control --- reactphp-cache-0.4.2/debian/control 2018-05-17 12:48:22.000000000 +0000 +++ reactphp-cache-0.5.0/debian/control 2018-12-02 12:59:33.000000000 +0000 @@ -7,7 +7,7 @@ Build-Depends: debhelper (>= 11), pkg-php-tools (>= 1.7~) -Standards-Version: 4.1.4 +Standards-Version: 4.2.1 Homepage: https://github.com/reactphp/cache Vcs-Git: https://salsa.debian.org/tdtf-team/reactphp-cache.git Vcs-Browser: https://salsa.debian.org/tdtf-team/reactphp-cache diff -Nru reactphp-cache-0.4.2/README.md reactphp-cache-0.5.0/README.md --- reactphp-cache-0.4.2/README.md 2017-12-20 16:47:13.000000000 +0000 +++ reactphp-cache-0.5.0/README.md 2018-06-25 12:52:40.000000000 +0000 @@ -18,7 +18,7 @@ * [CacheInterface](#cacheinterface) * [get()](#get) * [set()](#set) - * [remove()](#remove) + * [delete()](#delete) * [ArrayCache](#arraycache) * [Common usage](#common-usage) * [Fallback get](#fallback-get) @@ -37,6 +37,14 @@ #### get() +The `get(string $key, mixed $default = null): PromiseInterface` method can be used to +retrieve an item from the cache. + +This method will resolve with the cached value on success or with the +given `$default` value when no item can be found or when an error occurs. +Similarly, an expired cache item (once the time-to-live is expired) is +considered a cache miss. + ```php $cache ->get('foo') @@ -47,32 +55,48 @@ `var_dump` function. You can use any of the composition provided by [promises](https://github.com/reactphp/promise). -If the key `foo` does not exist, the promise will be rejected. - #### set() +The `set(string $key, mixed $value, ?float $ttl = null): PromiseInterface` method can be used to +store an item in the cache. + +This method will resolve with `true` on success or `false` when an error +occurs. If the cache implementation has to go over the network to store +it, it may take a while. + +The optional `$ttl` parameter sets the maximum time-to-live in seconds +for this cache item. If this parameter is omitted (or `null`), the item +will stay in the cache for as long as the underlying implementation +supports. Trying to access an expired cache item results in a cache miss, +see also [`get()`](#get). + ```php -$cache->set('foo', 'bar'); +$cache->set('foo', 'bar', 60); ``` This example eventually sets the value of the key `foo` to `bar`. If it -already exists, it is overridden. No guarantees are made as to when the cache -value is set. If the cache implementation has to go over the network to store -it, it may take a while. +already exists, it is overridden. -#### remove() +#### delete() + +Deletes an item from the cache. + +This method will resolve with `true` on success or `false` when an error +occurs. When no item for `$key` is found in the cache, it also resolves +to `true`. If the cache implementation has to go over the network to +delete it, it may take a while. ```php -$cache->remove('foo'); +$cache->delete('foo'); ``` -This example eventually removes the key `foo` from the cache. As with `set`, -this may not happen instantly. +This example eventually deletes the key `foo` from the cache. As with +`set()`, this may not happen instantly and a promise is returned to +provide guarantees whether or not the item has been removed from cache. ### ArrayCache -The `ArrayCache` provides an in-memory implementation of the -[`CacheInterface`](#cacheinterface). +The `ArrayCache` provides an in-memory implementation of the [`CacheInterface`](#cacheinterface). ```php $cache = new ArrayCache(); @@ -80,6 +104,22 @@ $cache->set('foo', 'bar'); ``` +Its constructor accepts an optional `?int $limit` parameter to limit the +maximum number of entries to store in the LRU cache. If you add more +entries to this instance, it will automatically take care of removing +the one that was least recently used (LRU). + +For example, this snippet will overwrite the first value and only store +the last two entries: + +```php +$cache = new ArrayCache(2); + +$cache->set('foo', '1'); +$cache->set('bar', '2'); +$cache->set('baz', '3'); +``` + ## Common usage ### Fallback get @@ -91,14 +131,20 @@ ```php $cache ->get('foo') - ->then(null, 'getFooFromDb') + ->then(function ($result) { + if ($result === null) { + return getFooFromDb(); + } + + return $result; + }) ->then('var_dump'); ``` -First an attempt is made to retrieve the value of `foo`. A promise rejection -handler of the function `getFooFromDb` is registered. `getFooFromDb` is a -function (can be any PHP callable) that will be called if the key does not -exist in the cache. +First an attempt is made to retrieve the value of `foo`. A callback function is +registered that will call `getFooFromDb` when the resulting value is null. +`getFooFromDb` is a function (can be any PHP callable) that will be called if the +key does not exist in the cache. `getFooFromDb` can handle the missing key by returning a promise for the actual value from the database (or any other data source). As a result, this @@ -112,7 +158,13 @@ ```php $cache ->get('foo') - ->then(null, array($this, 'getAndCacheFooFromDb')) + ->then(function ($result) { + if ($result === null) { + return $this->getAndCacheFooFromDb(); + } + + return $result; + }) ->then('var_dump'); public function getAndCacheFooFromDb() @@ -141,7 +193,7 @@ This will install the latest supported version: ```bash -$ composer require react/cache:^0.4.2 +$ composer require react/cache:^0.5.0 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. diff -Nru reactphp-cache-0.4.2/src/ArrayCache.php reactphp-cache-0.5.0/src/ArrayCache.php --- reactphp-cache-0.4.2/src/ArrayCache.php 2017-12-20 16:47:13.000000000 +0000 +++ reactphp-cache-0.5.0/src/ArrayCache.php 2018-06-25 12:52:40.000000000 +0000 @@ -6,24 +6,97 @@ class ArrayCache implements CacheInterface { + private $limit; private $data = array(); + private $expires = array(); - public function get($key) + /** + * The `ArrayCache` provides an in-memory implementation of the [`CacheInterface`](#cacheinterface). + * + * ```php + * $cache = new ArrayCache(); + * + * $cache->set('foo', 'bar'); + * ``` + * + * Its constructor accepts an optional `?int $limit` parameter to limit the + * maximum number of entries to store in the LRU cache. If you add more + * entries to this instance, it will automatically take care of removing + * the one that was least recently used (LRU). + * + * For example, this snippet will overwrite the first value and only store + * the last two entries: + * + * ```php + * $cache = new ArrayCache(2); + * + * $cache->set('foo', '1'); + * $cache->set('bar', '2'); + * $cache->set('baz', '3'); + * ``` + * + * @param int|null $limit maximum number of entries to store in the LRU cache + */ + public function __construct($limit = null) { - if (!isset($this->data[$key])) { - return Promise\reject(); + $this->limit = $limit; + } + + public function get($key, $default = null) + { + // delete key if it is already expired => below will detect this as a cache miss + if (isset($this->expires[$key]) && $this->expires[$key] < microtime(true)) { + unset($this->data[$key], $this->expires[$key]); + } + + if (!array_key_exists($key, $this->data)) { + return Promise\resolve($default); } - return Promise\resolve($this->data[$key]); + // remove and append to end of array to keep track of LRU info + $value = $this->data[$key]; + unset($this->data[$key]); + $this->data[$key] = $value; + + return Promise\resolve($value); } - public function set($key, $value) + public function set($key, $value, $ttl = null) { + // unset before setting to ensure this entry will be added to end of array (LRU info) + unset($this->data[$key]); $this->data[$key] = $value; + + // sort expiration times if TTL is given (first will expire first) + unset($this->expires[$key]); + if ($ttl !== null) { + $this->expires[$key] = microtime(true) + $ttl; + asort($this->expires); + } + + // ensure size limit is not exceeded or remove first entry from array + if ($this->limit !== null && count($this->data) > $this->limit) { + // first try to check if there's any expired entry + // expiration times are sorted, so we can simply look at the first one + reset($this->expires); + $key = key($this->expires); + + // check to see if the first in the list of expiring keys is already expired + // if the first key is not expired, we have to overwrite by using LRU info + if ($key === null || $this->expires[$key] > microtime(true)) { + reset($this->data); + $key = key($this->data); + } + unset($this->data[$key], $this->expires[$key]); + } + + return Promise\resolve(true); } - public function remove($key) + public function delete($key) { - unset($this->data[$key]); + unset($this->data[$key], $this->expires[$key]); + + return Promise\resolve(true); } } diff -Nru reactphp-cache-0.4.2/src/CacheInterface.php reactphp-cache-0.5.0/src/CacheInterface.php --- reactphp-cache-0.4.2/src/CacheInterface.php 2017-12-20 16:47:13.000000000 +0000 +++ reactphp-cache-0.5.0/src/CacheInterface.php 2018-06-25 12:52:40.000000000 +0000 @@ -2,12 +2,79 @@ namespace React\Cache; +use React\Promise\PromiseInterface; + interface CacheInterface { - // @return React\Promise\PromiseInterface - public function get($key); + /** + * Retrieves an item from the cache. + * + * This method will resolve with the cached value on success or with the + * given `$default` value when no item can be found or when an error occurs. + * Similarly, an expired cache item (once the time-to-live is expired) is + * considered a cache miss. + * + * ```php + * $cache + * ->get('foo') + * ->then('var_dump'); + * ``` + * + * This example fetches the value of the key `foo` and passes it to the + * `var_dump` function. You can use any of the composition provided by + * [promises](https://github.com/reactphp/promise). + * + * @param string $key + * @param mixed $default Default value to return for cache miss or null if not given. + * @return PromiseInterface + */ + public function get($key, $default = null); - public function set($key, $value); + /** + * Stores an item in the cache. + * + * This method will resolve with `true` on success or `false` when an error + * occurs. If the cache implementation has to go over the network to store + * it, it may take a while. + * + * The optional `$ttl` parameter sets the maximum time-to-live in seconds + * for this cache item. If this parameter is omitted (or `null`), the item + * will stay in the cache for as long as the underlying implementation + * supports. Trying to access an expired cache item results in a cache miss, + * see also [`get()`](#get). + * + * ```php + * $cache->set('foo', 'bar', 60); + * ``` + * + * This example eventually sets the value of the key `foo` to `bar`. If it + * already exists, it is overridden. + * + * @param string $key + * @param mixed $value + * @param ?float $ttl + * @return PromiseInterface Returns a promise which resolves to `true` on success or `false` on error + */ + public function set($key, $value, $ttl = null); - public function remove($key); + /** + * Deletes an item from the cache. + * + * This method will resolve with `true` on success or `false` when an error + * occurs. When no item for `$key` is found in the cache, it also resolves + * to `true`. If the cache implementation has to go over the network to + * delete it, it may take a while. + * + * ```php + * $cache->delete('foo'); + * ``` + * + * This example eventually deletes the key `foo` from the cache. As with + * `set()`, this may not happen instantly and a promise is returned to + * provide guarantees whether or not the item has been removed from cache. + * + * @param string $key + * @return PromiseInterface Returns a promise which resolves to `true` on success or `false` on error + */ + public function delete($key); } diff -Nru reactphp-cache-0.4.2/tests/ArrayCacheTest.php reactphp-cache-0.5.0/tests/ArrayCacheTest.php --- reactphp-cache-0.4.2/tests/ArrayCacheTest.php 2017-12-20 16:47:13.000000000 +0000 +++ reactphp-cache-0.5.0/tests/ArrayCacheTest.php 2018-06-25 12:52:40.000000000 +0000 @@ -6,6 +6,9 @@ class ArrayCacheTest extends TestCase { + /** + * @var ArrayCache + */ private $cache; public function setUp() @@ -14,22 +17,36 @@ } /** @test */ - public function getShouldRejectPromiseForNonExistentKey() + public function getShouldResolvePromiseWithNullForNonExistentKey() { + $success = $this->createCallableMock(); + $success + ->expects($this->once()) + ->method('__invoke') + ->with(null); + $this->cache ->get('foo') ->then( - $this->expectCallableNever(), - $this->expectCallableOnce() + $success, + $this->expectCallableNever() ); } /** @test */ public function setShouldSetKey() { - $this->cache + $setPromise = $this->cache ->set('foo', 'bar'); + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(true)); + + $setPromise->then($mock); + $success = $this->createCallableMock(); $success ->expects($this->once()) @@ -42,19 +59,138 @@ } /** @test */ - public function removeShouldRemoveKey() + public function deleteShouldDeleteKey() { $this->cache ->set('foo', 'bar'); - $this->cache - ->remove('foo'); + $deletePromise = $this->cache + ->delete('foo'); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(true)); + + $deletePromise->then($mock); $this->cache ->get('foo') ->then( - $this->expectCallableNever(), - $this->expectCallableOnce() + $this->expectCallableOnce(), + $this->expectCallableNever() ); } + + public function testGetWillResolveWithNullForCacheMiss() + { + $this->cache = new ArrayCache(); + + $this->cache->get('foo')->then($this->expectCallableOnceWith(null)); + } + + public function testGetWillResolveWithDefaultValueForCacheMiss() + { + $this->cache = new ArrayCache(); + + $this->cache->get('foo', 'bar')->then($this->expectCallableOnceWith('bar')); + } + + public function testGetWillResolveWithExplicitNullValueForCacheHit() + { + $this->cache = new ArrayCache(); + + $this->cache->set('foo', null); + $this->cache->get('foo', 'bar')->then($this->expectCallableOnceWith(null)); + } + + public function testLimitSizeToZeroDoesNotStoreAnyData() + { + $this->cache = new ArrayCache(0); + + $this->cache->set('foo', 'bar'); + + $this->cache->get('foo')->then($this->expectCallableOnceWith(null)); + } + + public function testLimitSizeToOneWillOnlyReturnLastWrite() + { + $this->cache = new ArrayCache(1); + + $this->cache->set('foo', '1'); + $this->cache->set('bar', '2'); + + $this->cache->get('foo')->then($this->expectCallableOnceWith(null)); + $this->cache->get('bar')->then($this->expectCallableOnceWith('2')); + } + + public function testOverwriteWithLimitedSizeWillUpdateLRUInfo() + { + $this->cache = new ArrayCache(2); + + $this->cache->set('foo', '1'); + $this->cache->set('bar', '2'); + $this->cache->set('foo', '3'); + $this->cache->set('baz', '4'); + + $this->cache->get('foo')->then($this->expectCallableOnceWith('3')); + $this->cache->get('bar')->then($this->expectCallableOnceWith(null)); + $this->cache->get('baz')->then($this->expectCallableOnceWith('4')); + } + + public function testGetWithLimitedSizeWillUpdateLRUInfo() + { + $this->cache = new ArrayCache(2); + + $this->cache->set('foo', '1'); + $this->cache->set('bar', '2'); + $this->cache->get('foo')->then($this->expectCallableOnceWith('1')); + $this->cache->set('baz', '3'); + + $this->cache->get('foo')->then($this->expectCallableOnceWith('1')); + $this->cache->get('bar')->then($this->expectCallableOnceWith(null)); + $this->cache->get('baz')->then($this->expectCallableOnceWith('3')); + } + + public function testGetWillResolveWithValueIfItemIsNotExpired() + { + $this->cache = new ArrayCache(); + + $this->cache->set('foo', '1', 10); + + $this->cache->get('foo')->then($this->expectCallableOnceWith('1')); + } + + public function testGetWillResolveWithDefaultIfItemIsExpired() + { + $this->cache = new ArrayCache(); + + $this->cache->set('foo', '1', 0); + + $this->cache->get('foo')->then($this->expectCallableOnceWith(null)); + } + + public function testSetWillOverwritOldestItemIfNoEntryIsExpired() + { + $this->cache = new ArrayCache(2); + + $this->cache->set('foo', '1', 10); + $this->cache->set('bar', '2', 20); + $this->cache->set('baz', '3', 30); + + $this->cache->get('foo')->then($this->expectCallableOnceWith(null)); + } + + public function testSetWillOverwriteExpiredItemIfAnyEntryIsExpired() + { + $this->cache = new ArrayCache(2); + + $this->cache->set('foo', '1', 10); + $this->cache->set('bar', '2', 0); + $this->cache->set('baz', '3', 30); + + $this->cache->get('foo')->then($this->expectCallableOnceWith('1')); + $this->cache->get('bar')->then($this->expectCallableOnceWith(null)); + } } diff -Nru reactphp-cache-0.4.2/tests/TestCase.php reactphp-cache-0.5.0/tests/TestCase.php --- reactphp-cache-0.4.2/tests/TestCase.php 2017-12-20 16:47:13.000000000 +0000 +++ reactphp-cache-0.5.0/tests/TestCase.php 2018-06-25 12:52:40.000000000 +0000 @@ -26,6 +26,17 @@ return $mock; } + protected function expectCallableOnceWith($param) + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($param); + + return $mock; + } + protected function expectCallableNever() { $mock = $this->createCallableMock();