From baf390c954031c890b11e0840bed43d596a4b5a9 Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic Date: Sat, 7 Sep 2024 12:28:56 +0200 Subject: [PATCH 1/3] Add support for Drupal cache tags --- modules/next/config/install/next.settings.yml | 1 + modules/next/config/schema/next.schema.yml | 3 + modules/next/next.install | 93 +++++++ modules/next/next.services.yml | 21 ++ modules/next/src/CacheTagNodeMapper.php | 92 +++++++ .../next/src/CacheTagNodeMapperInterface.php | 75 ++++++ .../next/src/CacheTagRevalidatorTaskStore.php | 69 +++++ .../CacheTagRevalidatorTaskStoreInterface.php | 54 ++++ .../ResourceResponseSubscriber.php | 191 ++++++++++++++ modules/next/src/Form/NextSettingsForm.php | 14 + modules/next/src/PathRevalidatorHelper.php | 128 +++++++++ .../src/PathRevalidatorHelperInterface.php | 47 ++++ .../src/Plugin/Next/Revalidator/CacheTag.php | 242 ++++++++++++++++++ .../QueueWorker/CacheTagRevalidator.php | 92 +++++++ .../ResourceResponseSubscriberTest.php | 240 +++++++++++++++++ .../src/Kernel/CacheTagNodeMapperTest.php | 165 ++++++++++++ .../CacheTagRevalidatorTaskStoreTest.php | 63 +++++ 17 files changed, 1590 insertions(+) create mode 100644 modules/next/src/CacheTagNodeMapper.php create mode 100644 modules/next/src/CacheTagNodeMapperInterface.php create mode 100644 modules/next/src/CacheTagRevalidatorTaskStore.php create mode 100644 modules/next/src/CacheTagRevalidatorTaskStoreInterface.php create mode 100644 modules/next/src/EventSubscriber/ResourceResponseSubscriber.php create mode 100644 modules/next/src/PathRevalidatorHelper.php create mode 100644 modules/next/src/PathRevalidatorHelperInterface.php create mode 100644 modules/next/src/Plugin/Next/Revalidator/CacheTag.php create mode 100644 modules/next/src/Plugin/QueueWorker/CacheTagRevalidator.php create mode 100644 modules/next/tests/src/Functional/ResourceResponseSubscriberTest.php create mode 100644 modules/next/tests/src/Kernel/CacheTagNodeMapperTest.php create mode 100644 modules/next/tests/src/Kernel/CacheTagRevalidatorTaskStoreTest.php diff --git a/modules/next/config/install/next.settings.yml b/modules/next/config/install/next.settings.yml index 19f78b1b..50bb5d26 100644 --- a/modules/next/config/install/next.settings.yml +++ b/modules/next/config/install/next.settings.yml @@ -8,3 +8,4 @@ preview_url_generator: simple_oauth preview_url_generator_configuration: secret_expiration: 30 debug: false +queue_size: 10 diff --git a/modules/next/config/schema/next.schema.yml b/modules/next/config/schema/next.schema.yml index bde59648..4c9982dc 100644 --- a/modules/next/config/schema/next.schema.yml +++ b/modules/next/config/schema/next.schema.yml @@ -89,6 +89,9 @@ next.settings: type: next.preview_url_generator.configuration.[%parent.preview_url_generator] debug: type: boolean + queue_size: + type: integer + label: 'Queue size' next.site_previewer.configuration.iframe: type: mapping diff --git a/modules/next/next.install b/modules/next/next.install index 0afb4e60..c171c1c1 100644 --- a/modules/next/next.install +++ b/modules/next/next.install @@ -6,6 +6,85 @@ */ use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\Language\LanguageInterface; +use Drupal\next\CacheTagNodeMapperInterface; +use Drupal\next\CacheTagRevalidatorTaskStoreInterface; + +/** + * Implements hook_schema(). + */ +function next_schema() { + $schema = []; + + $schema[CacheTagNodeMapperInterface::TABLE] = [ + 'description' => 'Cache tags mapped to associated node.', + 'fields' => [ + 'tag' => [ + 'description' => 'Cache tag.', + 'type' => 'varchar_ascii', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ], + 'nid' => [ + 'description' => 'The node id using the cache tag.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'langcode' => [ + 'description' => 'The language code of the node.', + 'type' => 'varchar_ascii', + 'length' => 12, + 'not null' => TRUE, + 'default' => LanguageInterface::LANGCODE_NOT_SPECIFIED, + ], + 'next_site' => [ + 'description' => 'The associated next site.', + 'type' => 'varchar_ascii', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ], + ], + 'primary key' => ['tag', 'nid', 'langcode', 'next_site'], + 'indexes' => [ + 'tag_nid_langcode_next_site' => ['tag', 'nid', 'langcode', 'next_site'], + ], + ]; + + $schema[CacheTagRevalidatorTaskStoreInterface::TABLE] = [ + 'description' => 'Cache tag revalidator task storage.', + 'fields' => [ + 'nid' => [ + 'description' => 'The node id that needs to be revalidated.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'langcode' => [ + 'description' => 'The language code of the node.', + 'type' => 'varchar_ascii', + 'length' => 12, + 'not null' => TRUE, + 'default' => LanguageInterface::LANGCODE_NOT_SPECIFIED, + ], + 'next_site' => [ + 'description' => 'The associated next site.', + 'type' => 'varchar_ascii', + 'length' => 255, + 'not null' => TRUE, + 'default' => '', + ], + ], + 'primary key' => ['nid', 'langcode', 'next_site'], + 'indexes' => [ + 'nid_langcode_next_site' => ['nid', 'langcode', 'next_site'], + ], + ]; + + return $schema; +} /** * Set simple_oauth as the preview url generator. @@ -104,3 +183,17 @@ function next_update_9106() { $config->set('debug', FALSE) ->save(); } + +/** + * Install tables if module is already installed. + */ +function next_update_9107() { + $schema = \Drupal::database()->schema(); + $tables = next_schema(); + if (!$schema->tableExists(CacheTagNodeMapperInterface::TABLE)) { + $schema->createTable(CacheTagNodeMapperInterface::TABLE, $tables[CacheTagNodeMapperInterface::TABLE]); + } + if (!$schema->tableExists(CacheTagRevalidatorTaskStoreInterface::TABLE)) { + $schema->createTable(CacheTagRevalidatorTaskStoreInterface::TABLE, $tables[CacheTagRevalidatorTaskStoreInterface::TABLE]); + } +} diff --git a/modules/next/next.services.yml b/modules/next/next.services.yml index b5afca86..2269cee4 100644 --- a/modules/next/next.services.yml +++ b/modules/next/next.services.yml @@ -71,3 +71,24 @@ services: arguments: ['@event_dispatcher'] tags: - { name: needs_destruction } + next.resource_response.subscriber: + class: Drupal\next\EventSubscriber\ResourceResponseSubscriber + arguments: + - '@entity_type.manager' + - '@language_manager' + - '@next.cache_tag_node_mapper' + tags: + - { name: event_subscriber } + next.cache_tag_node_mapper: + class: Drupal\next\CacheTagNodeMapper + arguments: [ '@database' ] + next.cache_tag_revalidator_task_store: + class: Drupal\next\CacheTagRevalidatorTaskStore + arguments: [ '@database' ] + next.path_revalidor_helper: + class: Drupal\next\PathRevalidatorHelper + arguments: + - '@entity_type.manager' + - '@next.settings.manager' + - '@http_client' + - '@logger.channel.next' diff --git a/modules/next/src/CacheTagNodeMapper.php b/modules/next/src/CacheTagNodeMapper.php new file mode 100644 index 00000000..f29b4f17 --- /dev/null +++ b/modules/next/src/CacheTagNodeMapper.php @@ -0,0 +1,92 @@ +connection = $connection; + } + + /** + * {@inheritdoc} + */ + public function getCacheTagsByNid(int $nid, string $langcode, string $next_site): array { + return $this->connection->select(self::TABLE, 'c') + ->fields('c', ['tag']) + ->condition('c.nid', $nid) + ->condition('c.langcode', $langcode) + ->condition('c.next_site', $next_site) + ->execute() + ->fetchCol(); + } + + /** + * {@inheritdoc} + */ + public function getNidsByCacheTag(string $cache_tag, string $langcode, $next_site): array { + return $this->connection->select(self::TABLE, 'c') + ->fields('c', ['nid']) + ->condition('c.tag', $cache_tag) + ->condition('c.langcode', $langcode) + ->condition('c.next_site', $next_site) + ->distinct() + ->execute() + ->fetchCol(); + } + + /** + * {@inheritdoc} + */ + public function delete(array $cache_tags, string $langcode, ?int $nid = NULL, ?string $next_site = NULL): void { + $query = $this->connection->delete(self::TABLE) + ->condition('langcode', $langcode); + + if (count($cache_tags) > 1) { + $query->condition('tag', $cache_tags, 'IN'); + } + else { + $query->condition('tag', reset($cache_tags)); + } + + if ($nid) { + $query->condition('nid', $nid); + } + if ($next_site) { + $query->condition('next_site', $next_site); + } + + $query->execute(); + } + + /** + * {@inheritdoc} + */ + public function add(array $values): void { + $query = $this->connection->insert(self::TABLE) + ->fields(['tag', 'nid', 'langcode', 'next_site']); + foreach ($values as $row) { + $query->values($row); + } + $query->execute(); + } + +} diff --git a/modules/next/src/CacheTagNodeMapperInterface.php b/modules/next/src/CacheTagNodeMapperInterface.php new file mode 100644 index 00000000..a73f7d47 --- /dev/null +++ b/modules/next/src/CacheTagNodeMapperInterface.php @@ -0,0 +1,75 @@ + 'node:1', + * 'nid' => '1', + * 'langcode' => 'en', + * 'next_site' => example, + * ] + * ]. + */ + public function add(array $values): void; + +} diff --git a/modules/next/src/CacheTagRevalidatorTaskStore.php b/modules/next/src/CacheTagRevalidatorTaskStore.php new file mode 100644 index 00000000..b9c84942 --- /dev/null +++ b/modules/next/src/CacheTagRevalidatorTaskStore.php @@ -0,0 +1,69 @@ +connection = $connection; + } + + /** + * {@inheritdoc} + */ + public function set(array $nids, string $langcode, string $next_site): void { + $query = $this->connection->insert(self::TABLE) + ->fields(['nid', 'langcode', 'next_site']); + foreach ($nids as $nid) { + $query->values([ + 'nid' => $nid, + 'langcode' => $langcode, + 'next_site' => $next_site, + ]); + } + $query->execute(); + } + + /** + * {@inheritdoc} + */ + public function has(int $nid, string $langcode, string $next_site): bool { + return (bool) $this->connection->select(self::TABLE, 'c') + ->fields('c', ['nid']) + ->condition('c.nid', $nid) + ->condition('c.langcode', $langcode) + ->condition('c.next_site', $next_site) + ->execute() + ->fetchField(); + } + + /** + * {@inheritdoc} + */ + public function delete(array $nids, string $langcode, string $next_site): void { + $this->connection->delete(self::TABLE) + ->condition('nid', $nids, 'IN') + ->condition('langcode', $langcode) + ->condition('next_site', $next_site) + ->execute(); + } + +} diff --git a/modules/next/src/CacheTagRevalidatorTaskStoreInterface.php b/modules/next/src/CacheTagRevalidatorTaskStoreInterface.php new file mode 100644 index 00000000..9c8e123b --- /dev/null +++ b/modules/next/src/CacheTagRevalidatorTaskStoreInterface.php @@ -0,0 +1,54 @@ +entityTypeManager = $entity_type_manager; + $this->languageManager = $language_manager; + $this->cacheTagNodeMapper = $cache_tag_node_mapper; + } + + /** + * {@inheritdoc} + * + * @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber::getSubscribedEvents() + * @see \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber + */ + public static function getSubscribedEvents(): array { + // Run before the dynamic page cache subscriber (priority 100), so that + // Dynamic Page Cache can cache flattened responses. + $events[KernelEvents::RESPONSE][] = ['onResponse', 100]; + return $events; + } + + /** + * Retrieve cache tags from response and register them. + * + * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event + * The event to process. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + public function onResponse(ResponseEvent $event): void { + $response = $event->getResponse(); + $request = $event->getRequest(); + $entity = $request->attributes->get('entity'); + $next_site = $request->headers->get('X-NextJS-Site'); + + if ( + $request->getRequestFormat() !== 'api_json' || + $request->attributes->get('_controller') !== 'jsonapi.entity_resource:getIndividual' || + !$response instanceof CacheableResponseInterface || + !$entity instanceof NodeInterface || + empty($next_site) || + !$this->entityTypeManager->getStorage('next_site')->load($next_site) + ) { + return; + } + + $langcode = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId(); + + // Filter cache tags from response with only enabled next entity types. + $cache_tags = $this->filterCacheTagsByNextEntityTypes($response->getCacheableMetadata()->getCacheTags()); + + // Get existing stored cache tags for the associated node. + $existing_cache_tags = $this->cacheTagNodeMapper->getCacheTagsByNid($entity->id(), $langcode, $next_site); + + // Remove stored cache tags that are no longer active on the response. + $delete_cache_tags = []; + foreach ($existing_cache_tags as $cache_tag => $nid) { + if (!in_array($cache_tag, $cache_tags)) { + $delete_cache_tags[] = $cache_tag; + } + } + if (!empty($delete_cache_tags)) { + $this->cacheTagNodeMapper->delete($delete_cache_tags, $langcode, $entity->id(), $next_site); + } + + // Add new cache tags. + $rows = []; + foreach ($cache_tags as $cache_tag) { + if (!in_array($cache_tag, $existing_cache_tags)) { + $rows[] = [ + 'tag' => $cache_tag, + 'nid' => $entity->id(), + 'langcode' => $langcode, + 'next_site' => $next_site, + ]; + } + } + if (!empty($rows)) { + $this->cacheTagNodeMapper->add($rows); + } + } + + /** + * Filter the cache tags by active next entity types. + * + * @param array $cache_tags + * The cache tags to filter. + * + * @return array + * Returns array with filtered cache tags. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + private function filterCacheTagsByNextEntityTypes(array $cache_tags): array { + $storage = $this->entityTypeManager->getStorage('next_entity_type_config'); + /** @var \Drupal\next\Entity\NextEntityTypeConfigInterface[] $next_entity_types */ + $next_entity_types = $storage->loadByProperties([ + 'status' => TRUE, + 'revalidator' => 'cache_tag', + ]); + + // Collect enabled next entity types, mapped with bundles. + $entity_type_id_bundle_map = []; + foreach ($next_entity_types as $next_entity_type) { + $id = explode('.', $next_entity_type->id()); + $entity_type_id_bundle_map[$id[0]][] = $id[1]; + } + $entity_type_id_bundles_str = []; + foreach ($entity_type_id_bundle_map as $entity_type_id => $bundles) { + $entity_type_id_bundles_str[$entity_type_id] = implode('|', $bundles); + } + + $filtered_cache_tags = []; + foreach ($cache_tags as $cache_tag) { + // Extract the first part of the cache tag, which is the entity type id. + $cache_tag_parts = explode(':', $cache_tag); + $entity_type_id = $cache_tag_parts[0]; + // Support entity list cache tags. + if (strpos($entity_type_id, '_list')) { + $entity_type_id = str_replace('_list', '', $entity_type_id); + } + // Check if entity type is enabled in next. + if (array_key_exists($entity_type_id, $entity_type_id_bundles_str)) { + $bundles_str = $entity_type_id_bundles_str[$entity_type_id]; + // Check for associated entity cache tags, including list cache tags. + if (preg_match("/^$entity_type_id(?:_list|_list:(?:$bundles_str)|:[0-9]+)$/", $cache_tag)) { + $filtered_cache_tags[] = $cache_tag; + } + } + } + + return $filtered_cache_tags; + } + +} diff --git a/modules/next/src/Form/NextSettingsForm.php b/modules/next/src/Form/NextSettingsForm.php index 9d1b4181..d0c90ff8 100644 --- a/modules/next/src/Form/NextSettingsForm.php +++ b/modules/next/src/Form/NextSettingsForm.php @@ -177,6 +177,19 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#default_value' => $config->get('debug'), ]; + $form['revalidate'] = [ + '#title' => $this->t('Revalidate'), + '#type' => 'details', + '#group' => 'settings', + 'queue_size' => [ + '#title' => $this->t('Queue size'), + '#description' => $this->t('Amount of nodes to be revalidated in a single queue.'), + '#type' => 'number', + '#default_value' => $config->get('queue_size'), + '#min' => 0, + ], + ]; + return parent::buildForm($form, $form_state); } @@ -259,6 +272,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) { ->set('preview_url_generator', $form_state->getValue('preview_url_generator')) ->set('preview_url_generator_configuration', $form_state->getValue('preview_url_generator_configuration')) ->set('debug', $form_state->getValue('debug')) + ->set('queue_size', $form_state->getValue('queue_size')) ->save(); } diff --git a/modules/next/src/PathRevalidatorHelper.php b/modules/next/src/PathRevalidatorHelper.php new file mode 100644 index 00000000..9583e162 --- /dev/null +++ b/modules/next/src/PathRevalidatorHelper.php @@ -0,0 +1,128 @@ +entityTypeManager = $entity_type_manager; + $this->nextSettingsManager = $next_settings_manager; + $this->httpClient = $http_client; + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public function revalidatePathByNodeIds( + array $nids, + string $langcode, + NextSiteInterface $site, + string $event_action + ): void { + /** @var \Drupal\node\NodeInterface[] $nodes */ + $nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($nids); + foreach ($nodes as $node) { + $translation = $node->getTranslation($langcode); + $path = $translation->toUrl()->toString(); + $this->revalidatePath($path, $site, $event_action); + } + } + + /** + * {@inheritdoc} + */ + public function revalidatePath( + string $path, + NextSiteInterface $site, + string $event_action + ): void { + try { + $revalidate_url = $site->getRevalidateUrlForPath($path); + + if (!$revalidate_url) { + throw new \Exception('No revalidate url set.'); + } + + if ($this->nextSettingsManager->isDebug()) { + $this->logger->notice('(@action): Revalidating path %path for the site %site. URL: %url', [ + '@action' => $event_action, + '%path' => $path, + '%site' => $site->label(), + '%url' => $revalidate_url->toString(), + ]); + } + + $response = $this->httpClient->request('GET', $revalidate_url->toString()); + if ($response && $response->getStatusCode() === Response::HTTP_OK) { + if ($this->nextSettingsManager->isDebug()) { + $this->logger->notice('(@action): Successfully revalidated path %path for the site %site. URL: %url', [ + '@action' => $event_action, + '%path' => $path, + '%site' => $site->label(), + '%url' => $revalidate_url->toString(), + ]); + } + } + } + catch (\Exception $exception) { + Error::logException($this->logger, $exception); + } + } + +} diff --git a/modules/next/src/PathRevalidatorHelperInterface.php b/modules/next/src/PathRevalidatorHelperInterface.php new file mode 100644 index 00000000..6d5f99d0 --- /dev/null +++ b/modules/next/src/PathRevalidatorHelperInterface.php @@ -0,0 +1,47 @@ +settingsConfig = $settings_config; + $this->cacheTagNodeMapper = $cache_tag_node_mapper; + $this->pathRevalidatorHelper = $path_revalidator_helper; + $this->queue = $queue; + $this->taskStore = $task_store; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('next.settings.manager'), + $container->get('http_client'), + $container->get('logger.channel.next'), + $container->get('config.factory')->get('next.settings'), + $container->get('next.cache_tag_node_mapper'), + $container->get('next.path_revalidor_helper'), + $container->get('queue')->get('cache_tag_revalidator', TRUE), + $container->get('next.cache_tag_revalidator_task_store') + ); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return []; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {} + + /** + * {@inheritdoc} + */ + public function revalidate(EntityActionEvent $event): bool { + $sites = $event->getSites(); + if (!count($sites)) { + return FALSE; + } + + $entity = $event->getEntity(); + $event_action = $event->getAction(); + $langcode = $entity->language()->getId(); + $queue_size = $this->settingsConfig->get('queue_size'); + + foreach ($sites as $site) { + $nids = []; + foreach ($this->getCacheTagsToInvalidate($entity) as $cache_tag) { + $nids = array_merge($nids, $this->cacheTagNodeMapper->getNidsByCacheTag($cache_tag, $langcode, $site->id())); + } + + // If the event is triggered on a node, we revalidate the associated + // node directly and remove it from the task store. + if ($entity instanceof NodeInterface) { + // Filter out the current node. + $nids = array_filter($nids, function ($nid) use ($entity) { + return $nid !== $entity->id(); + }); + + $this->pathRevalidatorHelper->revalidatePath($event->getEntityUrl(), $site, $event_action); + if ($this->taskStore->has($entity->id(), $langcode, $site->id())) { + $this->taskStore->delete([$entity->id()], $langcode, $site->id()); + } + } + + // If queue size is not available, we revalidate all associated nodes + // directly. + if (!$queue_size) { + $this->pathRevalidatorHelper->revalidatePathByNodeIds($nids, $langcode, $site, $event_action); + continue; + } + + // Filter out the node id's that are already queued. + $nids = array_filter($nids, function ($nid) use ($langcode, $site) { + return !$this->taskStore->has($nid, $langcode, $site->id()); + }); + + foreach (array_chunk($nids, $queue_size) as $order => $nids_chunk) { + // If the queue is empty and we are dealing with the first array chunk, + // the items get directly revalidated. + if (!$this->queue->numberOfItems() && !$order) { + $this->pathRevalidatorHelper->revalidatePathByNodeIds($nids_chunk, $langcode, $site, $event_action); + continue; + } + + $data = [ + 'nids' => $nids_chunk, + 'langcode' => $langcode, + 'site' => $site, + 'event_action' => $event_action, + ]; + $this->queue->createItem($data); + $this->taskStore->set($nids_chunk, $langcode, $site->id()); + } + } + + // On delete action remove associated cache tags. + if ($event->getAction() === 'delete') { + $this->cacheTagNodeMapper->delete($entity->getCacheTags(), $langcode); + } + + return TRUE; + } + + /** + * The cache tags to invalidate for this entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to retrieve cache tags from. + * + * @return string[] + * Set of list cache tags. + */ + private function getCacheTagsToInvalidate(EntityInterface $entity) { + $tags = $entity->getEntityType()->getListCacheTags(); + if ($entity->getEntityType()->hasKey('bundle')) { + $tags[] = $entity->getEntityTypeId() . '_list:' . $entity->bundle(); + } + return array_merge($entity->getCacheTags(), $tags); + } + +} diff --git a/modules/next/src/Plugin/QueueWorker/CacheTagRevalidator.php b/modules/next/src/Plugin/QueueWorker/CacheTagRevalidator.php new file mode 100644 index 00000000..6f1a5ddd --- /dev/null +++ b/modules/next/src/Plugin/QueueWorker/CacheTagRevalidator.php @@ -0,0 +1,92 @@ +pathRevalidatorHelper = $path_revalidator_helper; + $this->taskStore = $task_store; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('next.path_revalidor_helper'), + $container->get('next.cache_tag_revalidator_task_store') + ); + } + + /** + * {@inheritdoc} + */ + public function processItem($data) { + $this->pathRevalidatorHelper->revalidatePathByNodeIds( + $data['nids'], + $data['langcode'], + $data['site'], + $data['event_action'] + ); + $this->taskStore->delete( + $data['nids'], + $data['langcode'], + $data['site']->id() + ); + } + +} diff --git a/modules/next/tests/src/Functional/ResourceResponseSubscriberTest.php b/modules/next/tests/src/Functional/ResourceResponseSubscriberTest.php new file mode 100644 index 00000000..8921d984 --- /dev/null +++ b/modules/next/tests/src/Functional/ResourceResponseSubscriberTest.php @@ -0,0 +1,240 @@ +cacheTagNodeMapper = $this->container->get('next.cache_tag_node_mapper'); + + ConfigurableLanguage::createFromLangcode('nl')->save(); + \Drupal::configFactory()->getEditable('language.negotiation') + ->set('url.prefixes.en', 'en') + ->set('url.prefixes.nl', 'nl') + ->save(); + + NodeType::create([ + 'type' => 'article', + ])->save(); + + ContentLanguageSettings::create([ + 'target_entity_type_id' => 'node', + 'target_bundle' => 'article', + ]) + ->setThirdPartySetting('content_translation', 'enabled', TRUE) + ->save(); + + Vocabulary::create([ + 'vid' => 'tags', + 'name' => 'Tags', + ])->save(); + $this->createEntityReferenceField( + 'node', + 'article', + 'field_tags', + 'Tags', + 'taxonomy_term', + 'default', + [ + 'target_bundles' => [ + 'tags' => 'tags', + ], + 'auto_create' => TRUE, + ], + FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED + ); + + NextSite::create([ + 'id' => 'test', + 'revalidate_url' => 'http://test.com/api/revalidate', + 'revalidate_secret' => '12345', + ])->save(); + + foreach (['node' => 'article', 'taxonomy_term' => 'tags'] as $entity_type => $bundle) { + NextEntityTypeConfig::create([ + 'id' => "{$entity_type}.{$bundle}", + 'preview_enabled' => FALSE, + 'site_resolver' => 'site_selector', + 'configuration' => [ + 'sites' => [ + 'test' => 'test', + ], + ], + 'revalidator' => 'cache_tag', + ])->save(); + } + + $this->drupalLogin($this->drupalCreateUser(['access content'])); + \Drupal::service('router.builder')->rebuild(); + } + + /** + * Test jsonapi requests. + */ + public function testJsonApiRequest() { + $node = Node::create([ + 'type' => 'article', + 'title' => $this->randomString(), + ]); + $node->save(); + + // Not a jsonapi individual request. + $this->drupalGet($node->toUrl()->toString()); + $this->assertEmpty($this->cacheTagNodeMapper->getCacheTagsByNid($node->id(), 'en', 'test')); + + // A jsonapi individual request with missing X-NextJS-Site header. + $this->getIndividual($node); + $this->assertEmpty($this->cacheTagNodeMapper->getCacheTagsByNid($node->id(), 'en', 'test')); + + // A jsonapi individual request with incorrect X-NextJS-Site header value. + $this->getIndividual($node, [], ['X-NextJS-Site' => 'undefined']); + $this->assertEmpty($this->cacheTagNodeMapper->getCacheTagsByNid($node->id(), 'en', 'test')); + + $this->getIndividual($node, [], ['X-NextJS-Site' => 'test']); + $this->assertEquals( + $node->getCacheTags(), + $this->cacheTagNodeMapper->getCacheTagsByNid($node->id(), 'en', 'test') + ); + } + + /** + * Test multilingual jsonapi requests. + */ + public function testMultilingualJsonApiRequest() { + $node = Node::create([ + 'type' => 'article', + 'title' => $this->randomString(), + ]); + $node->addTranslation('nl', ['title' => $this->randomString()]); + $node->save(); + + $this->jsonapiGet("/nl/jsonapi/node/article/{$node->uuid()}", [], ['X-NextJS-Site' => 'test']); + $this->assertEquals( + $node->getCacheTags(), + $this->cacheTagNodeMapper->getCacheTagsByNid($node->id(), 'nl', 'test') + ); + } + + /** + * Test jsonapi requests with include. + */ + public function testJsonApiRequestWithInclude() { + $term = Term::create([ + 'vid' => 'tags', + 'name' => $this->randomString(), + ]); + $term->save(); + $node = Node::create([ + 'type' => 'article', + 'title' => $this->randomString(), + 'field_tags' => $term->id(), + ]); + $node->save(); + + $this->getIndividual( + $node, + ['query' => ['include' => 'field_tags']], + ['X-NextJS-Site' => 'test'] + ); + $this->assertEquals( + array_merge($node->getCacheTags(), $term->getCacheTags()), + $this->cacheTagNodeMapper->getCacheTagsByNid($node->id(), 'en', 'test') + ); + } + + /** + * Performs JSON:API request for the given entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to request. + * @param array $options + * URL options. + * @param array $headers + * Request headers. + */ + protected function getIndividual(EntityInterface $entity, array $options = [], array $headers = []): void { + $entity_type_id = $entity->getEntityTypeId(); + $bundle = $entity->bundle(); + $path = "/jsonapi/{$entity_type_id}/{$bundle}/{$entity->uuid()}"; + $this->jsonapiGet($path, $options, $headers); + } + + /** + * Performs JSON:API request. + * + * @param string $path + * The request path. + * @param array $options + * URL options. + * @param array $headers + * Request headers. + */ + protected function jsonapiGet(string $path, array $options = [], array $headers = []): void { + $this->drupalGet($path, $options, [ + 'Accept' => 'application/vnd.api+json', + ] + $headers); + } + +} diff --git a/modules/next/tests/src/Kernel/CacheTagNodeMapperTest.php b/modules/next/tests/src/Kernel/CacheTagNodeMapperTest.php new file mode 100644 index 00000000..b22e24b5 --- /dev/null +++ b/modules/next/tests/src/Kernel/CacheTagNodeMapperTest.php @@ -0,0 +1,165 @@ +installSchema('next', [ + CacheTagNodeMapperInterface::TABLE, + ]); + + $this->cacheTagNodeMapper = $this->container->get('next.cache_tag_node_mapper'); + + $this->cacheTagNodeMapper->add([ + ['node:1', 1, 'en', 'test'], + ['node:2', 1, 'nl', 'test'], + ['node:3', 1, 'en', 'test1'], + ['node:4', 2, 'en', 'test'], + ['taxonomy_term:1', 1, 'en', 'test'], + ['taxonomy_term:2', 1, 'nl', 'test'], + ['taxonomy_term:3', 1, 'en', 'test1'], + ['taxonomy_term:4', 2, 'en', 'test'], + ]); + } + + /** + * @covers ::getCacheTagsByNid + */ + public function testGetCacheTagsByNid() { + $this->assertEquals( + ['node:1', 'taxonomy_term:1'], + $this->cacheTagNodeMapper->getCacheTagsByNid(1, 'en', 'test') + ); + $this->assertEquals( + ['node:4', 'taxonomy_term:4'], + $this->cacheTagNodeMapper->getCacheTagsByNid(2, 'en', 'test') + ); + $this->assertEquals( + ['node:2', 'taxonomy_term:2'], + $this->cacheTagNodeMapper->getCacheTagsByNid(1, 'nl', 'test') + ); + $this->assertEquals( + ['node:3', 'taxonomy_term:3'], + $this->cacheTagNodeMapper->getCacheTagsByNid(1, 'en', 'test1') + ); + } + + /** + * @covers ::getNidsByCacheTag + */ + public function testGetNidsByCacheTag() { + $this->assertEquals( + [1], + $this->cacheTagNodeMapper->getNidsByCacheTag('node:1', 'en', 'test') + ); + $this->assertEquals( + [1], + $this->cacheTagNodeMapper->getNidsByCacheTag('node:2', 'nl', 'test') + ); + $this->assertEquals( + [1], + $this->cacheTagNodeMapper->getNidsByCacheTag('node:3', 'en', 'test1') + ); + $this->assertEquals( + [2], + $this->cacheTagNodeMapper->getNidsByCacheTag('node:4', 'en', 'test') + ); + $this->assertEquals( + [1], + $this->cacheTagNodeMapper->getNidsByCacheTag('taxonomy_term:1', 'en', 'test') + ); + $this->assertEquals( + [1], + $this->cacheTagNodeMapper->getNidsByCacheTag('taxonomy_term:2', 'nl', 'test') + ); + $this->assertEquals( + [1], + $this->cacheTagNodeMapper->getNidsByCacheTag('taxonomy_term:3', 'en', 'test1') + ); + $this->assertEquals( + [2], + $this->cacheTagNodeMapper->getNidsByCacheTag('taxonomy_term:4', 'en', 'test') + ); + } + + /** + * @covers ::delete + */ + public function testDelete() { + $this->cacheTagNodeMapper->add([ + ['node:5', 5, 'en', 'test'], + ['node:6', 6, 'en', 'test'], + ['node:7', 6, 'en', 'test'], + ['node:8', 8, 'en', 'test'], + ['node:8', 9, 'en', 'test'], + ['node:8', 9, 'en', 'test1'], + ]); + + // Single cache tag. + $cache_tags = ['node:5']; + $this->assertEquals( + $cache_tags, + $this->cacheTagNodeMapper->getCacheTagsByNid(5, 'en', 'test') + ); + $this->cacheTagNodeMapper->delete($cache_tags, 'en'); + $this->assertEmpty($this->cacheTagNodeMapper->getCacheTagsByNid(5, 'en', 'test')); + + // Multiple cache tags. + $cache_tags = ['node:6', 'node:7']; + $this->assertEquals( + $cache_tags, + $this->cacheTagNodeMapper->getCacheTagsByNid(6, 'en', 'test') + ); + $this->cacheTagNodeMapper->delete($cache_tags, 'en'); + $this->assertEmpty($this->cacheTagNodeMapper->getCacheTagsByNid(5, 'en', 'test')); + + // Single cache tag + specific nid. + $cache_tags = ['node:8']; + $this->assertEquals( + $cache_tags, + $this->cacheTagNodeMapper->getCacheTagsByNid(8, 'en', 'test') + ); + $this->cacheTagNodeMapper->delete($cache_tags, 'en', 8); + $this->assertEmpty($this->cacheTagNodeMapper->getCacheTagsByNid(8, 'en', 'test')); + + // Single cache tag + specific nid and next site. + $cache_tags = ['node:8']; + $this->assertEquals( + $cache_tags, + $this->cacheTagNodeMapper->getCacheTagsByNid(9, 'en', 'test1') + ); + $this->cacheTagNodeMapper->delete($cache_tags, 'en', 9, 'test1'); + $this->assertEmpty($this->cacheTagNodeMapper->getCacheTagsByNid(8, 'en', 'test1')); + } + +} diff --git a/modules/next/tests/src/Kernel/CacheTagRevalidatorTaskStoreTest.php b/modules/next/tests/src/Kernel/CacheTagRevalidatorTaskStoreTest.php new file mode 100644 index 00000000..39e3cc51 --- /dev/null +++ b/modules/next/tests/src/Kernel/CacheTagRevalidatorTaskStoreTest.php @@ -0,0 +1,63 @@ +installSchema('next', [ + CacheTagRevalidatorTaskStoreInterface::TABLE, + ]); + + $this->taskStore = $this->container->get('next.cache_tag_revalidator_task_store'); + } + + /** + * @covers ::set + * @covers ::has + * @covers ::delete + */ + public function testTaskStore() { + $this->taskStore->set([1, 2, 3], 'en', 'test'); + + $this->assertTrue($this->taskStore->has(1, 'en', 'test')); + $this->assertFalse($this->taskStore->has(1, 'nl', 'test')); + $this->assertFalse($this->taskStore->has(1, 'en', 'test1')); + $this->assertFalse($this->taskStore->has(4, 'en', 'test')); + + $this->taskStore->delete([1, 2], 'en', 'test'); + $this->assertFalse($this->taskStore->has(1, 'en', 'test')); + $this->assertFalse($this->taskStore->has(2, 'en', 'test')); + $this->assertTrue($this->taskStore->has(3, 'en', 'test')); + } + +} From 467349fec9d5780a8637472e66e4c6a29280476a Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic Date: Mon, 9 Sep 2024 07:48:22 +0200 Subject: [PATCH 2/3] Correct typo --- modules/next/next.services.yml | 2 +- modules/next/src/Plugin/Next/Revalidator/CacheTag.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/next/next.services.yml b/modules/next/next.services.yml index 2269cee4..49c52565 100644 --- a/modules/next/next.services.yml +++ b/modules/next/next.services.yml @@ -85,7 +85,7 @@ services: next.cache_tag_revalidator_task_store: class: Drupal\next\CacheTagRevalidatorTaskStore arguments: [ '@database' ] - next.path_revalidor_helper: + next.path_revalidator_helper: class: Drupal\next\PathRevalidatorHelper arguments: - '@entity_type.manager' diff --git a/modules/next/src/Plugin/Next/Revalidator/CacheTag.php b/modules/next/src/Plugin/Next/Revalidator/CacheTag.php index 43166f37..f71fd65e 100644 --- a/modules/next/src/Plugin/Next/Revalidator/CacheTag.php +++ b/modules/next/src/Plugin/Next/Revalidator/CacheTag.php @@ -124,7 +124,7 @@ public static function create(ContainerInterface $container, array $configuratio $container->get('logger.channel.next'), $container->get('config.factory')->get('next.settings'), $container->get('next.cache_tag_node_mapper'), - $container->get('next.path_revalidor_helper'), + $container->get('next.path_revalidator_helper'), $container->get('queue')->get('cache_tag_revalidator', TRUE), $container->get('next.cache_tag_revalidator_task_store') ); From d8f5e7e25deccee56043393c3056aafe9b1a49ec Mon Sep 17 00:00:00 2001 From: Bojan Bogdanovic Date: Mon, 9 Sep 2024 08:00:30 +0200 Subject: [PATCH 3/3] feat(next-drupal): fix spaces Removed spaces from next.services.yml --- modules/next/next.services.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/next/next.services.yml b/modules/next/next.services.yml index 49c52565..3bb37b5d 100644 --- a/modules/next/next.services.yml +++ b/modules/next/next.services.yml @@ -81,10 +81,10 @@ services: - { name: event_subscriber } next.cache_tag_node_mapper: class: Drupal\next\CacheTagNodeMapper - arguments: [ '@database' ] + arguments: ['@database'] next.cache_tag_revalidator_task_store: class: Drupal\next\CacheTagRevalidatorTaskStore - arguments: [ '@database' ] + arguments: ['@database'] next.path_revalidator_helper: class: Drupal\next\PathRevalidatorHelper arguments: