Skip to content

Instantly share code, notes, and snippets.

@tractorcow
Last active August 24, 2018 02:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tractorcow/fe6571d2d00340a311ca31a677a05a29 to your computer and use it in GitHub Desktop.
Save tractorcow/fe6571d2d00340a311ca31a677a05a29 to your computer and use it in GitHub Desktop.
GraphQL Caching POC
<?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()
]);
}
}
---
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'
<?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