Skip to content

Commit

Permalink
Added cache key generator interface and default implementation. (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
markchalloner authored and Bilge committed Mar 16, 2017
1 parent 506e1c6 commit e484fae
Show file tree
Hide file tree
Showing 7 changed files with 198 additions and 11 deletions.
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
------------

Expand Down
13 changes: 13 additions & 0 deletions src/Cache/CacheKeyGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php
namespace ScriptFUSION\Porter\Cache;

interface CacheKeyGenerator
{
/**
* @param string $source
* @param array $sortedOptions Options sorted by key.
*
* @return string A PSR-6 compatible cache key.
*/
public function generateCacheKey($source, array $sortedOptions);
}
7 changes: 7 additions & 0 deletions src/Cache/InvalidCacheKeyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php
namespace ScriptFUSION\Porter\Cache;

class InvalidCacheKeyException extends \RuntimeException implements \Psr\Cache\InvalidArgumentException
{
// Intentionally empty.
}
16 changes: 16 additions & 0 deletions src/Cache/JsonCacheKeyGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
namespace ScriptFUSION\Porter\Cache;

use ScriptFUSION\Porter\Connector\CachingConnector;

class JsonCacheKeyGenerator implements CacheKeyGenerator
{
public function generateCacheKey($source, array $sortedOptions)
{
return str_replace(
str_split(CachingConnector::RESERVED_CHARACTERS),
'.',
json_encode([$source, $sortedOptions], JSON_UNESCAPED_SLASHES)
);
}
}
63 changes: 54 additions & 9 deletions src/Connector/CachingConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
namespace ScriptFUSION\Porter\Connector;

use Psr\Cache\CacheItemPoolInterface;
use ScriptFUSION\Porter\Cache\CacheKeyGenerator;
use ScriptFUSION\Porter\Cache\CacheToggle;
use ScriptFUSION\Porter\Cache\InvalidCacheKeyException;
use ScriptFUSION\Porter\Cache\JsonCacheKeyGenerator;
use ScriptFUSION\Porter\Cache\MemoryCache;
use ScriptFUSION\Porter\Options\EncapsulatedOptions;

Expand All @@ -11,6 +14,8 @@
*/
abstract class CachingConnector implements Connector, CacheToggle
{
const RESERVED_CHARACTERS = '{}()/\@:';

/**
* @var CacheItemPoolInterface
*/
Expand All @@ -21,28 +26,42 @@ abstract class CachingConnector implements Connector, CacheToggle
*/
private $cacheEnabled = true;

public function __construct(CacheItemPoolInterface $cache = null)
/**
* @var CacheKeyGenerator
*/
private $cacheKeyGenerator;

public function __construct(CacheItemPoolInterface $cache = null, CacheKeyGenerator $cacheKeyGenerator = null)
{
$this->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;
}
Expand Down Expand Up @@ -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;
}
}
67 changes: 66 additions & 1 deletion test/Integration/Porter/Connector/CachingConnectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand All @@ -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 */
Expand All @@ -92,7 +157,7 @@ public function testEnableCache()

public function testCacheKeyExcludesReservedCharacters()
{
$reservedCharacters = '{}()/\@:';
$reservedCharacters = CachingConnector::RESERVED_CHARACTERS;

$this->connector->setCache($cache = \Mockery::spy(CacheItemPoolInterface::class));

Expand Down
19 changes: 19 additions & 0 deletions test/Unit/Porter/Cache/JsonCacheKeyGeneratorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php
namespace ScriptFUSIONTest\Unit\Porter\Cache;

use ScriptFUSION\Porter\Cache\JsonCacheKeyGenerator;
use ScriptFUSIONTest\Stubs\TestOptions;

final class JsonCacheKeyGeneratorTest extends \PHPUnit_Framework_TestCase
{
public function testGenerateCacheKey()
{
$options = new TestOptions;
$options->setFoo('(baz@quz\quux/quuz)');

self::assertSame(
'["bar",."foo".".baz.quz..quux.quuz.".]',
(new JsonCacheKeyGenerator)->generateCacheKey('bar', $options->copy())
);
}
}

0 comments on commit e484fae

Please sign in to comment.