From f7450ba5aa91e76a326c068bd085dff9f3d43a95 Mon Sep 17 00:00:00 2001 From: Kristiaan Van den Eynde Date: Wed, 15 May 2019 11:26:42 +0200 Subject: [PATCH] This patch title does not matter as it's a proof of concept. So I dub it "The patch of a million bees working hard to craft the sweetest honey Baloo has ever tasted" --- core/core.services.yml | 3 + core/lib/Drupal/Core/Cache/CacheRedirect.php | 29 ++ core/lib/Drupal/Core/Cache/VariationCache.php | 209 ++++++++++ .../Core/Cache/VariationCacheFactory.php | 64 +++ .../Cache/VariationCacheFactoryInterface.php | 21 + .../Core/Cache/VariationCacheInterface.php | 69 ++++ core/modules/jsonapi/jsonapi.services.yml | 18 + .../ResourceObjectNormalizationCacher.php | 148 +++++++ .../Normalizer/ResourceObjectNormalizer.php | 112 +++++- .../tests/src/Functional/ResourceTestBase.php | 3 + .../Tests/Core/Cache/VariationCacheTest.php | 370 ++++++++++++++++++ 11 files changed, 1030 insertions(+), 16 deletions(-) create mode 100644 core/lib/Drupal/Core/Cache/CacheRedirect.php create mode 100644 core/lib/Drupal/Core/Cache/VariationCache.php create mode 100644 core/lib/Drupal/Core/Cache/VariationCacheFactory.php create mode 100644 core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php create mode 100644 core/lib/Drupal/Core/Cache/VariationCacheInterface.php create mode 100644 core/modules/jsonapi/src/EventSubscriber/ResourceObjectNormalizationCacher.php create mode 100644 core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php diff --git a/core/core.services.yml b/core/core.services.yml index a4327bda9b..1137078d97 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -174,6 +174,9 @@ services: cache_contexts_manager: class: Drupal\Core\Cache\Context\CacheContextsManager arguments: ['@service_container', '%cache_contexts%' ] + variation_cache_factory: + class: Drupal\Core\Cache\VariationCacheFactory + arguments: ['@request_stack', '@cache_factory', '@cache_contexts_manager'] cache_tags.invalidator: parent: container.trait class: Drupal\Core\Cache\CacheTagsInvalidator diff --git a/core/lib/Drupal/Core/Cache/CacheRedirect.php b/core/lib/Drupal/Core/Cache/CacheRedirect.php new file mode 100644 index 0000000000..23674837be --- /dev/null +++ b/core/lib/Drupal/Core/Cache/CacheRedirect.php @@ -0,0 +1,29 @@ +cacheContexts = $cacheability->getCacheContexts(); + } + +} diff --git a/core/lib/Drupal/Core/Cache/VariationCache.php b/core/lib/Drupal/Core/Cache/VariationCache.php new file mode 100644 index 0000000000..68941a1f25 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/VariationCache.php @@ -0,0 +1,209 @@ +requestStack = $request_stack; + $this->cacheBackend = $cache_backend; + $this->cacheContextsManager = $cache_contexts_manager; + } + + /** + * {@inheritdoc} + */ + public function get(array $keys) { + $chain = $this->getRedirectChain($keys); + return array_pop($chain); + } + + /** + * {@inheritdoc} + */ + public function set(array $keys, $data, CacheableDependencyInterface $cacheability) { + // Don't store uncacheable items. + if ($cacheability->getCacheMaxAge() === 0) { + return; + } + + // We expect a CacheableMetadata object when creating cache IDs. + $cacheability = CacheableMetadata::createFromObject($cacheability); + $cid = $this->createCacheID($keys, $cacheability); + + // Check whether we had any cache redirects leading to the cache ID already. + // If there are none, we know that there is no proper redirect path to the + // cache ID we're trying to store the data at. This may be because there is + // either no full redirect path yet or there is one that is too specific at + // a given step of the way. In case of the former, we simply need to store a + // redirect. In case of the latter, we need to replace the overly specific + // step with a simpler one. + $chain = $this->getRedirectChain($keys); + if (!array_key_exists($cid, $chain)) { + $redirect = new CacheRedirect($cacheability); + + // We can easily find overly specific redirects by comparing their cache + // contexts to the ones we have here. If a redirect has more contexts, it + // needs to be dumbed down. + $data_contexts = $cacheability->getCacheContexts(); + foreach ($chain as $chain_cid => $result) { + if ($result && $result->data instanceof CacheRedirect) { + $result_contexts = $result->data->getCacheContexts(); + if (array_diff($result_contexts, $data_contexts)) { + break; + } + } + } + + // If we couldn't find an overly specific step, we know that the last + // cache ID in the chain is where the lookup failed. So we simply add a + // redirect with the current cacheability there. Either way, $chain_cid + // should now contain the cache ID we're interested in. + // + // Cache redirects are stored indefinitely and without tags as they never + // need to be cleared. If they ever end up leading to a stale cache item + // that now uses different contexts then said item will either follow an + // existing path of redirects or carve its own over the old one. + $this->cacheBackend->set($chain_cid, $redirect); + } + + $this->cacheBackend->set($cid, $data, $this->maxAgeToExpire($cacheability->getCacheMaxAge()), $cacheability->getCacheTags()); + } + + /** + * {@inheritdoc} + */ + public function delete(array $keys) { + $chain = $this->getRedirectChain($keys); + end($chain); + return $this->cacheBackend->delete(key($chain)); + } + + /** + * {@inheritdoc} + */ + public function invalidate(array $keys) { + $chain = $this->getRedirectChain($keys); + end($chain); + return $this->cacheBackend->invalidate(key($chain)); + } + + /** + * Performs a full get, returning every step of the way. + * + * This will check whether there is a cache redirect and follow it if so. It + * will keep following redirects until it gets to a cache miss or the actual + * cache object. + * + * @param string[] $keys + * The cache keys to retrieve the cache entry for. + * + * @return array + * Every cache get that lead to the final result, keyed by the cache ID used + * to query the cache for that result. + */ + protected function getRedirectChain(array $keys) { + $cid = implode(':', $keys); + $chain[$cid] = $result = $this->cacheBackend->get($cid); + + while ($result && $result->data instanceof CacheRedirect) { + $cid = $this->createRedirectedCacheID($keys, $result->data); + $chain[$cid] = $result = $this->cacheBackend->get($cid); + } + + return $chain; + } + + /** + * Maps a max-age value to an "expire" value for the Cache API. + * + * @param int $max_age + * A max-age value. + * + * @return int + * A corresponding "expire" value. + * + * @see \Drupal\Core\Cache\CacheBackendInterface::set() + */ + protected function maxAgeToExpire($max_age) { + if ($max_age !== Cache::PERMANENT) { + return (int) $this->requestStack->getMasterRequest()->server->get('REQUEST_TIME') + $max_age; + } + return $max_age; + } + + /** + * Creates a cache ID based on cache keys and cacheable metadata. + * + * @param string[] $keys + * The cache keys of the data to store. + * @param \Drupal\Core\Cache\CacheableMetadata $cacheable_metadata + * The cacheable metadata of the data to store. + * + * @return string + * The cache ID. + */ + protected function createCacheID(array $keys, CacheableMetadata &$cacheable_metadata) { + if ($contexts = $cacheable_metadata->getCacheContexts()) { + $context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($contexts); + $keys = array_merge($keys, $context_cache_keys->getKeys()); + $cacheable_metadata = $cacheable_metadata->merge($context_cache_keys); + } + return implode(':', $keys); + } + + /** + * Creates a redirected cache ID based on cache keys and a CacheRedirect. + * + * This is a simpler, faster version of ::createCacheID() because it is called + * many times during a request and cache redirects don't care about the effect + * that cache context optimizing might have on the cache tags. + * + * @param string[] $keys + * The cache keys of the data to store. + * @param \Drupal\Core\Cache\CacheRedirect $cache_redirect + * The cache redirect to store. + * + * @return string + * The cache ID for the redirect. + */ + protected function createRedirectedCacheID(array $keys, CacheRedirect $cache_redirect) { + $context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($cache_redirect->getCacheContexts()); + return implode(':', array_merge($keys, $context_cache_keys->getKeys())); + } + +} diff --git a/core/lib/Drupal/Core/Cache/VariationCacheFactory.php b/core/lib/Drupal/Core/Cache/VariationCacheFactory.php new file mode 100644 index 0000000000..0468ea2702 --- /dev/null +++ b/core/lib/Drupal/Core/Cache/VariationCacheFactory.php @@ -0,0 +1,64 @@ +requestStack = $request_stack; + $this->cacheFactory = $cache_factory; + $this->cacheContextsManager = $cache_contexts_manager; + } + + /** + * {@inheritdoc} + */ + public function get($bin) { + if (!isset($this->bins[$bin])) { + $this->bins[$bin] = new VariationCache($this->requestStack, $this->cacheFactory->get($bin), $this->cacheContextsManager); + } + return $this->bins[$bin]; + } + +} diff --git a/core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php b/core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php new file mode 100644 index 0000000000..e9c948813d --- /dev/null +++ b/core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php @@ -0,0 +1,21 @@ +variationCache = $variation_cache; + } + + /** + * Reads an entity normalization from cache. + * + * The returned normalization may only be a partial normalization because it + * was previously normalized with a sparse fieldset. + * + * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object + * The resource object for which to generate a cache item. + * + * @return array|false + * The cached normalization parts, or FALSE if not yet cached. + */ + public function get(ResourceObject $object) { + $cached = $this->variationCache->get([$object->getResourceType()->getTypeName(), $object->getId()]); + if ($cached) { + return $cached->data; + } + return FALSE; + } + + /** + * Adds a normalization to be cached after the response has been sent. + * + * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object + * The resource object for which to generate a cache item. + * @param array $normalization_parts + * The normalization parts to cache. + */ + public function saveLater(ResourceObject $object, array $normalization_parts) { + $resource_type = $object->getResourceType(); + $key = $resource_type->getTypeName() . ':' . $object->getId(); + $this->toCache[$key] = [$object, $normalization_parts]; + } + + /** + * Writes normalizations of entities to cache, if any were created. + * + * @param \Symfony\Component\HttpKernel\Event\PostResponseEvent $event + * The Event to process. + */ + public function onTerminate(PostResponseEvent $event) { + foreach ($this->toCache as $value) { + list($object, $normalization_parts) = $value; + $this->set($object, $normalization_parts); + } + } + + /** + * Writes a normalization to cache. + * + * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object + * The resource object for which to generate a cache item. + * @param array $normalization_parts + * The normalization parts to cache. + */ + protected function set(ResourceObject $object, array $normalization_parts) { + assert(array_keys($normalization_parts) === ['base', 'fields']); + + // Merge the entity's cacheability with that of the normalization parts. + $cacheable_metadata = CacheableMetadata::createFromObject($object) + ->addCacheContexts([ResourceVersionRouteEnhancer::CACHE_CONTEXT]) + ->merge(static::mergeCacheableDependencies($normalization_parts['fields'])); + + $this->variationCache->set( + [$object->getResourceType()->getTypeName(), $object->getId()], + $normalization_parts, + $cacheable_metadata + ); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[KernelEvents::TERMINATE][] = ['onTerminate']; + return $events; + } + + /** + * Determines the joint cacheability of all provided dependencies. + * + * @param \Drupal\Core\Cache\CacheableDependencyInterface|object[] $dependencies + * The dependencies. + * + * @return \Drupal\Core\Cache\CacheableMetadata + * The cacheability of all dependencies. + * + * @see \Drupal\Core\Cache\RefinableCacheableDependencyInterface::addCacheableDependency() + */ + protected static function mergeCacheableDependencies(array $dependencies) { + $merged_cacheability = new CacheableMetadata(); + array_walk($dependencies, function ($dependency) use ($merged_cacheability) { + $merged_cacheability->addCacheableDependency($dependency); + }); + return $merged_cacheability; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/ResourceObjectNormalizer.php b/core/modules/jsonapi/src/Normalizer/ResourceObjectNormalizer.php index 4e0f881e1c..56e77207c5 100644 --- a/core/modules/jsonapi/src/Normalizer/ResourceObjectNormalizer.php +++ b/core/modules/jsonapi/src/Normalizer/ResourceObjectNormalizer.php @@ -4,6 +4,7 @@ use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Field\FieldItemListInterface; +use Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher; use Drupal\jsonapi\JsonApiResource\ResourceObject; use Drupal\jsonapi\Normalizer\Value\CacheableNormalization; use Drupal\jsonapi\Normalizer\Value\CacheableOmission; @@ -24,6 +25,13 @@ class ResourceObjectNormalizer extends NormalizerBase { */ protected $supportedInterfaceOrClass = ResourceObject::class; + /** + * The entity normalization cacher. + * + * @var \Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher + */ + private $cacher; + /** * {@inheritdoc} */ @@ -31,6 +39,16 @@ public function supportsDenormalization($data, $type, $format = NULL) { return FALSE; } + /** + * Set the cacher service. + * + * @param \Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher $cacher + * The entity normalization cacher. + */ + public function setCacher(ResourceObjectNormalizationCacher $cacher) { + $this->cacher = $cacher; + } + /** * {@inheritdoc} */ @@ -49,23 +67,85 @@ public function normalize($object, $format = NULL, array $context = []) { else { $field_names = array_keys($fields); } - $normalizer_values = []; - foreach ($fields as $field_name => $field) { - $in_sparse_fieldset = in_array($field_name, $field_names); - // Omit fields not listed in sparse fieldsets. - if (!$in_sparse_fieldset) { - continue; - } - $normalizer_values[$field_name] = $this->serializeField($field, $context, $format); - } + + $normalization_parts = $this->getNormalization($field_names, $object, $format, $context); + + // Keep only the requested fields (the cached normalization gradually grows + // to the complete set of fields). + $field_normalizations = array_intersect_key($normalization_parts['fields'], array_flip($field_names)); + $relationship_field_names = array_keys($resource_type->getRelatableResourceTypes()); - return CacheableNormalization::aggregate([ - 'type' => CacheableNormalization::permanent($resource_type->getTypeName()), - 'id' => CacheableNormalization::permanent($object->getId()), - 'attributes' => CacheableNormalization::aggregate(array_diff_key($normalizer_values, array_flip($relationship_field_names)))->omitIfEmpty(), - 'relationships' => CacheableNormalization::aggregate(array_intersect_key($normalizer_values, array_flip($relationship_field_names)))->omitIfEmpty(), - 'links' => $this->serializer->normalize($object->getLinks(), $format, $context)->omitIfEmpty(), - ])->withCacheableDependency($object); + $attributes = array_diff_key($field_normalizations, array_flip($relationship_field_names)); + $relationships = array_intersect_key($field_normalizations, array_flip($relationship_field_names)); + $links = $this->serializer->normalize($object->getLinks(), $format, $context); + $resource_object_normalization = array_filter(array_merge( + $normalization_parts['base'], + [ + 'attributes' => CacheableNormalization::aggregate($attributes)->omitIfEmpty(), + 'relationships' => CacheableNormalization::aggregate($relationships)->omitIfEmpty(), + 'links' => $links->omitIfEmpty(), + ] + )); + return CacheableNormalization::aggregate($resource_object_normalization)->withCacheableDependency($object); + } + + /** + * Normalizes an entity using the given fieldset. + * + * @param string[] $field_names + * The field names to normalize (the sparse fieldset, if any). + * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object + * The resource object to partially normalize. + * @param string $format + * The format in which the normalization will be encoded. + * @param array $context + * Context options for the normalizer. + * + * @return array + * An array with two key-value pairs: + * - 'base': array, the base normalization of the entity, that does not + * depend on which sparse fieldset was requested. + * - 'fields': CacheableNormalization for all requested fields. + * + * @see ::normalize() + */ + private function getNormalization(array $field_names, ResourceObject $object, $format = NULL, array $context = []) { + $cached_normalization_parts = $this->cacher->get($object); + $normalizer_values = $cached_normalization_parts !== FALSE + ? $cached_normalization_parts + : static::buildEmptyNormalization($object); + $non_cached_fields = array_diff_key($object->getFields(), $normalizer_values['fields']); + $non_cached_requested_fields = array_intersect_key($non_cached_fields, array_flip($field_names)); + foreach ($non_cached_requested_fields as $field_name => $field) { + $normalizer_values['fields'][$field_name] = $this->serializeField($field, $context, $format); + } + + if (!empty($non_cached_requested_fields)) { + $this->cacher->saveLater($object, $normalizer_values); + } + + return $normalizer_values; + } + + /** + * Builds the empty normalization structure for cache misses. + * + * @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object + * The resource object being normalized. + * + * @return array + * The normalization structure as defined in ::getNormalization(). + * + * @see ::getNormalization() + */ + private static function buildEmptyNormalization(ResourceObject $object) { + return [ + 'base' => [ + 'type' => CacheableNormalization::permanent($object->getResourceType()->getTypeName()), + 'id' => CacheableNormalization::permanent($object->getId()), + ], + 'fields' => [], + ]; } /** diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php index f50c15804a..e39dc9fb53 100644 --- a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php +++ b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php @@ -377,6 +377,9 @@ protected function getData() { * The JSON:API normalization for the given entity. */ protected function normalize(EntityInterface $entity, Url $url) { + // Don't use cached normalizations in tests. + $this->container->get('cache.jsonapi_normalizations')->deleteAll(); + $self_link = new Link(new CacheableMetadata(), $url, ['self']); $resource_type = $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName); $doc = new JsonApiDocumentTopLevel(new ResourceObjectData([ResourceObject::createFromEntity($resource_type, $entity)], 1), new NullIncludedData(), new LinkCollection(['self' => $self_link])); diff --git a/core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php b/core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php new file mode 100644 index 0000000000..6cbe0e5792 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php @@ -0,0 +1,370 @@ +requestStack = $this->prophesize(RequestStack::class); + $this->memoryBackend = new MemoryBackend(); + $this->cacheContextsManager = $this->prophesize(CacheContextsManager::class); + + $housing_type = &$this->housingType; + $garden_type = &$this->gardenType; + $house_orientation = &$this->houseOrientation; + $this->cacheContextsManager->convertTokensToKeys(Argument::any()) + ->will(function ($args) use (&$housing_type, &$garden_type, &$house_orientation) { + $keys = []; + foreach ($args[0] as $context_id) { + switch ($context_id) { + case 'house.type': + $keys[] = "ht.$housing_type"; + break; + case 'garden.type': + $keys[] = "gt.$garden_type"; + break; + case 'house.orientation': + $keys[] = "ho.$house_orientation"; + break; + default: + $keys[] = $context_id; + } + } + return new ContextCacheKeys($keys); + }); + + $this->variationCache = new VariationCache( + $this->requestStack->reveal(), + $this->memoryBackend, + $this->cacheContextsManager->reveal() + ); + + $this->housingTypeCacheability = (new CacheableMetadata()) + ->setCacheTags(['foo']) + ->setCacheContexts(['house.type']); + $this->gardenTypeCacheability = (new CacheableMetadata()) + ->setCacheTags(['bar']) + ->setCacheContexts(['house.type', 'garden.type']); + $this->houseOrientationCacheability = (new CacheableMetadata()) + ->setCacheTags(['baz']) + ->setCacheContexts(['house.type', 'garden.type', 'house.orientation']); + } + + /** + * Tests a non-variable cache item. + * + * @covers ::get + * @covers ::set + */ + public function testNoVariations() { + $this->cacheContextsManager->convertTokensToKeys()->shouldNotBeCalled(); + $this->assertVariationCacheMiss(); + + $cacheability = (new CacheableMetadata())->setCacheTags(['foo']); + $this->setVariationCacheItem('I never vary.', $cacheability); + $this->assertVariationCacheItem('I never vary.', $cacheability); + $this->assertCacheBackendItem($this->cacheIdBase, 'I never vary.', $cacheability); + } + + /** + * Tests a cache item that only ever varies by one context. + * + * @covers ::get + * @covers ::set + */ + public function testSingleVariation() { + $cacheability = $this->housingTypeCacheability; + + $house_data = [ + 'apartment' => 'You have a nice apartment', + 'house' => 'You have a nice house', + ]; + + foreach ($house_data as $housing_type => $data) { + $this->housingType = $housing_type; + $this->assertVariationCacheMiss(); + $this->setVariationCacheItem($data, $cacheability); + $this->assertVariationCacheItem($data, $cacheability); + $this->assertCacheBackendItem($this->cacheIdBase, new CacheRedirect($cacheability)); + $this->assertCacheBackendItem("$this->cacheIdBase:ht.$housing_type", $data, $cacheability); + } + } + + /** + * Tests a cache item that has nested variations. + * + * @covers ::get + * @covers ::set + */ + public function testNestedVariations() { + // We are running this scenario in the best possible outcome: The redirects + // are stored in expanding order, meaning the simplest one is stored first + // and the nested ones are stored in subsequent ::set() calls. This means no + // self-healing takes place where overly specific redirects are overwritten + // with simpler ones. + $possible_outcomes = [ + 'apartment' => 'You have a nice apartment!', + 'house|no-garden' => 'You have a nice house!', + 'house|garden|east' => 'You have a nice house with an east-facing garden!', + 'house|garden|south' => 'You have a nice house with a south-facing garden!', + 'house|garden|west' => 'You have a nice house with a west-facing garden!', + 'house|garden|north' => 'You have a nice house with a north-facing garden!', + ]; + + foreach ($possible_outcomes as $cache_context_values => $data) { + list($this->housingType, $this->gardenType, $this->houseOrientation) = explode('|', $cache_context_values . '||'); + + $cacheability = $this->housingTypeCacheability; + if (!empty($this->houseOrientation)) { + $cacheability = $this->houseOrientationCacheability; + } + elseif (!empty($this->gardenType)) { + $cacheability = $this->gardenTypeCacheability; + } + + $this->assertVariationCacheMiss(); + $this->setVariationCacheItem($data, $cacheability); + + $cache_id = $this->cacheIdBase; + $this->assertVariationCacheItem($data, $cacheability); + $this->assertCacheBackendItem($cache_id, new CacheRedirect($this->housingTypeCacheability)); + + $cache_id .= ":ht.$this->housingType"; + if (!empty($this->gardenType)) { + $this->assertCacheBackendItem($cache_id, new CacheRedirect($this->gardenTypeCacheability)); + $cache_id .= ":gt.$this->gardenType"; + } + if (!empty($this->houseOrientation)) { + $this->assertCacheBackendItem($cache_id, new CacheRedirect($this->houseOrientationCacheability)); + $cache_id .= ":ho.$this->houseOrientation"; + } + + $this->assertCacheBackendItem($cache_id, $data, $cacheability); + } + } + + /** + * Tests a cache item that has nested variations that trigger self-healing. + * + * @covers ::get + * @covers ::set + * + * @depends testNestedVariations + */ + public function testNestedVariationsSelfHealing() { + // This is the worst possible scenario: A very specific item was stored + // first, followed by a less specific one. This means an overly specific + // cache redirect was stored that needs to be dumbed down. After this + // process, the first ::get() for the more specific item will fail as we + // have effectively destroyed the path to said item. Setting an item of the + // same specificity will restore the path for all items of said specificity. + $possible_outcomes = [ + 'house|garden|east' => 'You have a nice house with an east-facing garden!', + 'house|garden|south' => 'You have a nice house with a south-facing garden!', + 'house|garden|west' => 'You have a nice house with a west-facing garden!', + 'house|garden|north' => 'You have a nice house with a north-facing garden!', + ]; + + foreach ($possible_outcomes as $cache_context_values => $data) { + list($this->housingType, $this->gardenType, $this->houseOrientation) = explode('|', $cache_context_values . '||'); + $this->setVariationCacheItem($data, $this->houseOrientationCacheability); + } + + // Verify that the overly specific redirect is stored at the first possible + // redirect location, i.e.: The base cache ID. + $this->assertCacheBackendItem($this->cacheIdBase, new CacheRedirect($this->houseOrientationCacheability)); + + // Store a simpler variation and verify that the first cache redirect is now + // the one redirecting to the simplest possible outcome. + list($this->housingType, $this->gardenType, $this->houseOrientation) = ['apartment',NULL,NULL]; + $this->setVariationCacheItem($data, $this->housingTypeCacheability); + $this->assertCacheBackendItem($this->cacheIdBase, new CacheRedirect($this->housingTypeCacheability)); + + // Verify that the previously set outcomes are all inaccessible now. + foreach ($possible_outcomes as $cache_context_values => $data) { + list($this->housingType, $this->gardenType, $this->houseOrientation) = explode('|', $cache_context_values . '||'); + $this->assertVariationCacheMiss(); + } + + // Set at least one more specific item in the cache again. + $this->setVariationCacheItem($data, $this->houseOrientationCacheability); + + // Verify that the previously set outcomes are all accessible again. + foreach ($possible_outcomes as $cache_context_values => $data) { + list($this->housingType, $this->gardenType, $this->houseOrientation) = explode('|', $cache_context_values . '||'); + $this->assertVariationCacheItem($data, $this->houseOrientationCacheability); + } + + // Verify that the more specific cache redirect is now stored one step after + // the less specific one. + $this->assertCacheBackendItem("$this->cacheIdBase:ht.house", new CacheRedirect($this->houseOrientationCacheability)); + } + + /** + * Stores an item in the variation cache. + * + * @param mixed $data + * The data that should be stored. + * @param \Drupal\Core\Cache\CacheableMetadata $cacheability + * The cacheability that should be used. + */ + protected function setVariationCacheItem($data, CacheableMetadata $cacheability) { + $this->variationCache->set($this->cacheKeys, $data, $cacheability); + } + + /** + * Asserts that an item was properly stored in the variation cache. + * + * @param mixed $data + * The data that should have been stored. + * @param \Drupal\Core\Cache\CacheableMetadata $cacheability + * The cacheability that should have been used. + */ + protected function assertVariationCacheItem($data, CacheableMetadata $cacheability) { + $cache_item = $this->variationCache->get($this->cacheKeys); + $this->assertNotFalse($cache_item, 'Variable data was stored and retrieved successfully.'); + $this->assertEquals($data, $cache_item->data, 'Variable cache item contains the right data.'); + $this->assertSame($cacheability->getCacheTags(), $cache_item->tags, 'Variable cache item uses the right cache tags.'); + } + + /** + * Asserts that an item could not be retrieved from the variation cache. + */ + protected function assertVariationCacheMiss() { + $this->assertFalse($this->variationCache->get($this->cacheKeys), 'Nothing could be retrieved for the active cache contexts.'); + } + + /** + * Asserts that an item was properly stored in the cache backend. + * + * @param string $cid + * The cache ID that should have been used. + * @param mixed $data + * The data that should have been stored. + * @param \Drupal\Core\Cache\CacheableMetadata|null $cacheability + * (optional) The cacheability that should have been used. Does not apply + * when checking for cache redirects. + */ + protected function assertCacheBackendItem($cid, $data, CacheableMetadata $cacheability = NULL) { + $cache_backend_item = $this->memoryBackend->get($cid); + $this->assertNotFalse($cache_backend_item, 'The data was stored and retrieved successfully.'); + $this->assertEquals($data, $cache_backend_item->data, 'Cache item contains the right data.'); + + if ($data instanceof CacheRedirect) { + $this->assertSame([], $cache_backend_item->tags, 'A cache redirect does not use cache tags.'); + $this->assertSame(-1, $cache_backend_item->expire, 'A cache redirect is stored indefinitely.'); + } + else { + $this->assertSame($cacheability->getCacheTags(), $cache_backend_item->tags, 'Cache item uses the right cache tags.'); + } + } + +} -- 2.17.1