-
-
Save tractorcow/fe6571d2d00340a311ca31a677a05a29 to your computer and use it in GitHub Desktop.
GraphQL Caching POC
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace SilverStripe\GraphQL\Middleware; | |
use GraphQL\Executor\ExecutionResult; | |
use Psr\SimpleCache\CacheInterface; | |
use Psr\SimpleCache\InvalidArgumentException; | |
use SilverStripe\GraphQL\Manager; | |
use SilverStripe\ORM\DataObject; | |
use SilverStripe\ORM\FieldType\DBDatetime; | |
/** | |
* Enables graphql responses to be cached. | |
* Internally uses QueryRecorderExtension to determine which records are queried in order to generate given responses. | |
*/ | |
class CacheMiddleware implements QueryMiddleware | |
{ | |
/** | |
* @var CacheInterface | |
*/ | |
protected $cache; | |
/** | |
* @param string $query | |
* @param array $params | |
* @param callable $next | |
* @return array|ExecutionResult | |
* @throws InvalidArgumentException | |
*/ | |
public function process($query, $params, callable $next) | |
{ | |
$key = $this->generateCacheKey($query, $params); | |
// Get successful cache response | |
$response = $this->getCachedResponse($key); | |
if ($response) { | |
return $response; | |
} | |
// Closure begins / ends recording of classes queried by DataQuery. | |
// ClassSpyExtension is added to DataQuery via yml | |
$spy = QueryRecorderExtension::singleton(); | |
list ($classesUsed, $response) = $spy->recordClasses(function () use ($query, $params, $next) { | |
return $next($query, $params); | |
}); | |
// Save freshly generated response | |
$this->storeCache($key, $response, $classesUsed); | |
return $response; | |
} | |
/** | |
* @return CacheInterface | |
*/ | |
public function getCache() | |
{ | |
return $this->cache; | |
} | |
/** | |
* @param CacheInterface $cache | |
* @return $this | |
*/ | |
public function setCache($cache) | |
{ | |
$this->cache = $cache; | |
return $this; | |
} | |
/** | |
* Generate cache key | |
* | |
* @param string $query | |
* @param array $params | |
* @return string | |
*/ | |
protected function generateCacheKey($query, $params): string | |
{ | |
return md5(var_export( | |
[ | |
'query' => $query, | |
'params' => $params | |
], | |
true | |
)); | |
} | |
/** | |
* Get and validate cached response. | |
* | |
* Note: Cached responses can only be returned in array format, not object format. | |
* | |
* @param string $key | |
* @return array | |
* @throws InvalidArgumentException | |
*/ | |
protected function getCachedResponse($key) | |
{ | |
// Initially check if the cached value exists at all | |
$cache = $this->getCache(); | |
$cached = $cache->get($key); | |
if (!isset($cached)) { | |
return null; | |
} | |
// On cache success validate against cached classes | |
foreach ($cached['classes'] as $class) { | |
// Note: Could combine these clases into a UNION to cut down on extravagant queries | |
// Todo: We can get last-deleted/modified as well for versioned records | |
$lastEditedDate = DataObject::get($class)->max('LastEdited'); | |
if (strtotime($lastEditedDate) > strtotime($cached['date'])) { | |
// class modified, fail validation of cache | |
return null; | |
} | |
} | |
// On cache success + validation | |
return $cached['response']; | |
} | |
/** | |
* Send a successful response to the cache | |
* | |
* @param string $key | |
* @param ExecutionResult|array $response | |
* @param array $classesUsed | |
* @throws InvalidArgumentException | |
*/ | |
protected function storeCache($key, $response, $classesUsed) | |
{ | |
// Ensure we store serialisable version of result | |
if ($response instanceof ExecutionResult) { | |
$response = Manager::singleton()->serialiseResult($response); | |
} | |
$this->getCache()->set($key, [ | |
'classes' => $classesUsed, | |
'response' => $response, | |
'date' => DBDatetime::now()->getValue() | |
]); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- | |
Name: graphqlcaching | |
--- | |
# Enable query recording on dataobjects | |
SilverStripe\ORM\DataObject: | |
extensions: | |
QueryRecorderExtension: SilverStripe\GraphQL\Middleware\QueryRecorderExtension | |
# Add caching middleware to manager | |
SilverStripe\Core\Injector\Injector: | |
Psr\SimpleCache\CacheInterface.graphql: | |
factory: SilverStripe\Core\Cache\CacheFactory | |
constructor: | |
namespace: "graphql" | |
defaultLifetime: 600 | |
SilverStripe\GraphQL\Middleware\CacheMiddleware: | |
properties: | |
Cache: '%$Psr\SimpleCache\CacheInterface.graphql' | |
SilverStripe\GraphQL\Manager: | |
properties: | |
Middlewares: | |
CacheMiddleware: '%$SilverStripe\GraphQL\Middleware\CacheMiddleware' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace SilverStripe\GraphQL\Middleware; | |
use SilverStripe\Core\Injector\Injectable; | |
use SilverStripe\ORM\DataExtension; | |
use SilverStripe\ORM\DataQuery; | |
use SilverStripe\ORM\Queries\SQLSelect; | |
/** | |
* Attaches itself to {@see DataQuery} and records any classes that are queried within a closure context. | |
* Allows code to measure and detect affected classes within any operation. E.g. for caching. | |
*/ | |
class QueryRecorderExtension extends DataExtension | |
{ | |
use Injectable; | |
/** | |
* List of scopes, each of which contains a list of classes mapped from lowercase class name to cased class name | |
* | |
* @var string[][] | |
*/ | |
protected $levels = []; | |
/** | |
* Record query against a given class | |
* | |
* @param SQLSelect $select | |
* @param DataQuery $query | |
*/ | |
public function augmentDataQueryCreation(SQLSelect $select, DataQuery $query) | |
{ | |
// Skip if disabled | |
if (empty($this->levels)) { | |
return; | |
} | |
// Add class to all nested levels | |
$class = $query->dataClass(); | |
for( $i = 0; $i < count($this->levels); $i++) { | |
$this->levels[$i][strtolower($class)] = $class; | |
} | |
} | |
/** | |
* Create a new nesting level, record all classes queried during the callback, and unnest. | |
* Returns an array containing [ $listOfClasses, $resultOfCallback ] | |
* | |
* @param callable $callback | |
* @return array Two-length array with list of classes and result of callback | |
*/ | |
public function recordClasses(callable $callback) | |
{ | |
// Create nesting level | |
$this->levels[] = []; | |
try { | |
$result = $callback(); | |
$classes = end($this->levels); | |
return [$classes, $result]; | |
} finally { | |
// Reset scope after callback completes | |
array_pop($this->levels); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment