diff --git a/README.md b/README.md index 1947a3e..71af2dd 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,9 @@ Caching Caching is available at the connector level if the connector implements `CacheToggle`. Connectors typically extend `CachingConnector` which implements [PSR-6][PSR-6]-compatible caching. Porter ships with just one cache implementation, `MemoryCache`, which stores data in memory but this can be substituted for any PSR-6 cache if the connector permits it. -When available, the connector caches raw responses for each unique cache key. The cache key is comprised of the source and options parameters passed to `Connector::fetch`. Options are sorted before the cache key is created so the order of options are insignificant. +When available, the connector caches raw responses for each unique [cache key](#cache-key). Cache keys are generated by an implementation-defined strategy or the default `JsonCacheKeyGenerator` strategy. + +### Cache advice Caching behaviour is specified by one of the `CacheAdvice` enumeration constants listed below. @@ -212,6 +214,26 @@ $records = $porter->import( ); ``` +### Cache key + +The cache key can optionally be generated by an implementation of `CacheKeyGeneratorInterface` if the connector permits it. This implementation should provide one method `generateCacheKey` which returns a [PSR-6][PSR-6]-compatible cache key. + +The default implementation `JsonCacheKeyGenerator` generates keys comprised of the source and options parameters passed to `Connector::fetch`. Options are sorted before the cache key is created so the order of options are insignificant. + +#### Implementation example + +The following example demonstrates a simple cache key generation implementation using an md5 hash of the json encoded parameters. + +```php +class MyCacheKeyGenerator implements CacheKeyGenerator +{ + public function generateCacheKey($source, array $sortedOptions) + { + return md5(json_encode([$source, $optionsSorted])); + } +} +``` + Architecture ------------ diff --git a/src/Cache/CacheKeyGenerator.php b/src/Cache/CacheKeyGenerator.php new file mode 100644 index 0000000..239b168 --- /dev/null +++ b/src/Cache/CacheKeyGenerator.php @@ -0,0 +1,13 @@ +cache = $cache ?: new MemoryCache; + $this->cacheKeyGenerator = $cacheKeyGenerator ?: new JsonCacheKeyGenerator; } + /** + * @param string $source + * @param EncapsulatedOptions|null $options + * + * @return mixed + * + * @throws InvalidCacheKeyException + */ public function fetch($source, EncapsulatedOptions $options = null) { - $optionsCopy = $options ? $options->copy() : []; - if ($this->isCacheEnabled()) { + $optionsCopy = $options ? $options->copy() : []; + ksort($optionsCopy); - $hash = $this->hash([$source, $optionsCopy]); + $key = $this->validateCacheKey($this->getCacheKeyGenerator()->generateCacheKey($source, $optionsCopy)); - if ($this->cache->hasItem($hash)) { - return $this->cache->getItem($hash)->get(); + if ($this->cache->hasItem($key)) { + return $this->cache->getItem($key)->get(); } } $data = $this->fetchFreshData($source, $options); - isset($hash) && $this->cache->save($this->cache->getItem($hash)->set($data)); + isset($key) && $this->cache->save($this->cache->getItem($key)->set($data)); return $data; } @@ -74,8 +93,34 @@ public function isCacheEnabled() return $this->cacheEnabled; } - private function hash(array $structure) + public function getCacheKeyGenerator() { - return str_replace(str_split('{}()/\@:'), '.', json_encode($structure, JSON_UNESCAPED_SLASHES)); + return $this->cacheKeyGenerator; + } + + public function setCacheKeyGenerator(CacheKeyGenerator $cacheKeyGenerator) + { + $this->cacheKeyGenerator = $cacheKeyGenerator; + } + + /** + * @param mixed $key + * + * @return string + * + * @throws InvalidCacheKeyException + */ + private function validateCacheKey($key) + { + if (!is_string($key)) { + throw new InvalidCacheKeyException('Cache key must be of type string.'); + } + if (strpbrk($key, self::RESERVED_CHARACTERS) !== false) { + throw new InvalidCacheKeyException( + sprintf('Cache key "%s" contains one or more reserved characters: "%s"', $key, self::RESERVED_CHARACTERS) + ); + } + + return $key; } } diff --git a/test/Integration/Porter/Connector/CachingConnectorTest.php b/test/Integration/Porter/Connector/CachingConnectorTest.php index f69cff1..4345561 100644 --- a/test/Integration/Porter/Connector/CachingConnectorTest.php +++ b/test/Integration/Porter/Connector/CachingConnectorTest.php @@ -5,6 +5,9 @@ use Mockery\MockInterface; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; +use ScriptFUSION\Porter\Cache\CacheKeyGenerator; +use ScriptFUSION\Porter\Cache\InvalidCacheKeyException; +use ScriptFUSION\Porter\Cache\JsonCacheKeyGenerator; use ScriptFUSION\Porter\Cache\MemoryCache; use ScriptFUSION\Porter\Connector\CachingConnector; use ScriptFUSION\Porter\Options\EncapsulatedOptions; @@ -53,6 +56,15 @@ public function testGetSetCache() self::assertSame($cache, $this->connector->getCache()); } + public function testGetSetCacheKeyGenerator() + { + self::assertInstanceOf(CacheKeyGenerator::class, $this->connector->getCacheKeyGenerator()); + self::assertNotSame($cacheKeyGenerator = new JsonCacheKeyGenerator, $this->connector->getCacheKeyGenerator()); + + $this->connector->setCacheKeyGenerator($cacheKeyGenerator); + self::assertSame($cacheKeyGenerator, $this->connector->getCacheKeyGenerator()); + } + public function testCacheBypassedForDifferentOptions() { self::assertSame('foo', $this->connector->fetch('baz', $this->options)); @@ -68,6 +80,59 @@ public function testCacheUsedForDifferentOptionsInstance() self::assertSame('foo', $this->connector->fetch('baz', clone $this->options)); } + public function testCacheUsedForCacheKeyGenerator() + { + $this->connector->setCacheKeyGenerator( + \Mockery::mock(CacheKeyGenerator::class) + ->shouldReceive('generateCacheKey') + ->with('quux', $this->options->copy()) + ->andReturn('quuz', 'quuz', 'corge') + ->getMock() + ); + + self::assertSame('foo', $this->connector->fetch('quux', $this->options)); + self::assertSame('foo', $this->connector->fetch('quux', $this->options)); + self::assertSame('bar', $this->connector->fetch('quux', $this->options)); + } + + public function testFetchThrowsInvalidCacheKeyExceptionOnNonStringCackeKey() + { + $this->setExpectedException(InvalidCacheKeyException::class, 'Cache key must be of type string.'); + + $this->connector->setCacheKeyGenerator( + \Mockery::mock(CacheKeyGenerator::class) + ->shouldReceive('generateCacheKey') + ->with('quux', $this->options->copy()) + ->andReturn([]) + ->getMock() + ); + + $this->connector->fetch('quux', $this->options); + } + + public function testFetchThrowsInvalidCacheKeyExceptionOnNonPSR6CompliantCacheKey() + { + $cacheKey = CachingConnector::RESERVED_CHARACTERS; + + $this->setExpectedException( + InvalidCacheKeyException::class, + sprintf('Cache key "%s" contains one or more reserved characters: "%s"', + $cacheKey, + CachingConnector::RESERVED_CHARACTERS + ) + ); + + $this->connector->setCacheKeyGenerator( + \Mockery::mock(CacheKeyGenerator::class) + ->shouldReceive('generateCacheKey') + ->with('quux', $this->options->copy()) + ->andReturn($cacheKey) + ->getMock() + ); + + $this->connector->fetch('quux', $this->options); + } + public function testNullAndEmptyAreEquivalent() { /** @var EncapsulatedOptions $options */ @@ -92,7 +157,7 @@ public function testEnableCache() public function testCacheKeyExcludesReservedCharacters() { - $reservedCharacters = '{}()/\@:'; + $reservedCharacters = CachingConnector::RESERVED_CHARACTERS; $this->connector->setCache($cache = \Mockery::spy(CacheItemPoolInterface::class)); diff --git a/test/Unit/Porter/Cache/JsonCacheKeyGeneratorTest.php b/test/Unit/Porter/Cache/JsonCacheKeyGeneratorTest.php new file mode 100644 index 0000000..82dac30 --- /dev/null +++ b/test/Unit/Porter/Cache/JsonCacheKeyGeneratorTest.php @@ -0,0 +1,19 @@ +setFoo('(baz@quz\quux/quuz)'); + + self::assertSame( + '["bar",."foo".".baz.quz..quux.quuz.".]', + (new JsonCacheKeyGenerator)->generateCacheKey('bar', $options->copy()) + ); + } +}