diff --git a/search_api.module b/search_api.module index ccbd9990..d6463975 100644 --- a/search_api.module +++ b/search_api.module @@ -24,6 +24,7 @@ use Drupal\search_api\Plugin\views\query\SearchApiQuery; use Drupal\search_api\SearchApiException; use Drupal\search_api\Task\IndexTaskManager; +use Drupal\search_api\Tracker\TrackerPluginBase; use Drupal\views\ViewEntityInterface; use Drupal\views\ViewExecutable; use Drupal\Core\Entity\Display\EntityViewDisplayInterface; @@ -190,151 +191,68 @@ function search_api_config_import_steps_alter(&$sync_steps, ConfigImporter $conf /** * Implements hook_entity_insert(). * - * Adds entries for all languages of the new entity to the tracking table for - * each index that tracks entities of this type. - * - * By setting the $entity->search_api_skip_tracking property to a true-like - * value before this hook is invoked, you can prevent this behavior and make the - * Search API ignore this new entity. - * * Note that this function implements tracking only on behalf of the "Content * Entity" datasource defined in this module, not for entity-based datasources * in general. Datasources defined by other modules still have to implement * their own mechanism for tracking new/updated/deleted entities. * - * @see \Drupal\search_api\Plugin\search_api\datasource\ContentEntity + * @see \Drupal\search_api\Plugin\search_api\datasource\ContentEntityTrackingManager::entityInsert() */ function search_api_entity_insert(EntityInterface $entity) { - // Check if the entity is a content entity. - if (!($entity instanceof ContentEntityInterface) || $entity->search_api_skip_tracking) { - return; - } - $indexes = ContentEntity::getIndexesForEntity($entity); - if (!$indexes) { - return; - } - - // Compute the item IDs for all languages of the entity. - $item_ids = []; - $entity_id = $entity->id(); - foreach (array_keys($entity->getTranslationLanguages()) as $langcode) { - $item_ids[] = $entity_id . ':' . $langcode; - } - $datasource_id = 'entity:' . $entity->getEntityTypeId(); - foreach ($indexes as $index) { - $filtered_item_ids = ContentEntity::filterValidItemIds($index, $datasource_id, $item_ids); - $index->trackItemsInserted($datasource_id, $filtered_item_ids); - } + // Call this hook on behalf of the Content Entity datasource. + \Drupal::getContainer()->get('search_api.entity_datasource.tracking_manager') + ->entityInsert($entity); } /** * Implements hook_entity_update(). * - * Updates the corresponding tracking table entries for each index that tracks - * this entity. - * - * Also takes care of new or deleted translations. - * - * By setting the $entity->search_api_skip_tracking property to a true-like - * value before this hook is invoked, you can prevent this behavior and make the - * Search API ignore this update. - * * Note that this function implements tracking only on behalf of the "Content * Entity" datasource defined in this module, not for entity-based datasources * in general. Datasources defined by other modules still have to implement * their own mechanism for tracking new/updated/deleted entities. * - * @see \Drupal\search_api\Plugin\search_api\datasource\ContentEntity + * Independent of datasources, however, this will also call + * \Drupal\search_api\Utility\TrackingHelper::trackReferencedEntityUpdate() to + * attempt to mark all items for reindexing that indirectly indexed changed + * fields of this entity. + * + * @see \Drupal\search_api\Plugin\search_api\datasource\ContentEntityTrackingManager::entityUpdate() + * @see \Drupal\search_api\Utility\TrackingHelper::trackReferencedEntityUpdate() */ function search_api_entity_update(EntityInterface $entity) { - // Check if the entity is a content entity. - if (!($entity instanceof ContentEntityInterface) || $entity->search_api_skip_tracking) { - return; - } - $indexes = ContentEntity::getIndexesForEntity($entity); - if (!$indexes) { - return; - } + // Call this hook on behalf of the Content Entity datasource. + \Drupal::getContainer()->get('search_api.entity_datasource.tracking_manager') + ->entityUpdate($entity); - // Compare old and new languages for the entity to identify inserted, - // updated and deleted translations (and, therefore, search items). - $entity_id = $entity->id(); - $inserted_item_ids = []; - $updated_item_ids = $entity->getTranslationLanguages(); - $deleted_item_ids = []; - $old_translations = $entity->original->getTranslationLanguages(); - foreach ($old_translations as $langcode => $language) { - if (!isset($updated_item_ids[$langcode])) { - $deleted_item_ids[] = $langcode; - } - } - foreach ($updated_item_ids as $langcode => $language) { - if (!isset($old_translations[$langcode])) { - unset($updated_item_ids[$langcode]); - $inserted_item_ids[] = $langcode; - } - } - - $datasource_id = 'entity:' . $entity->getEntityTypeId(); - $combine_id = function ($langcode) use ($entity_id) { - return $entity_id . ':' . $langcode; - }; - $inserted_item_ids = array_map($combine_id, $inserted_item_ids); - $updated_item_ids = array_map($combine_id, array_keys($updated_item_ids)); - $deleted_item_ids = array_map($combine_id, $deleted_item_ids); - foreach ($indexes as $index) { - if ($inserted_item_ids) { - $filtered_item_ids = ContentEntity::filterValidItemIds($index, $datasource_id, $inserted_item_ids); - $index->trackItemsInserted($datasource_id, $filtered_item_ids); - } - if ($updated_item_ids) { - $index->trackItemsUpdated($datasource_id, $updated_item_ids); - } - if ($deleted_item_ids) { - $index->trackItemsDeleted($datasource_id, $deleted_item_ids); - } - } + // Attempt to track all items as changed that indexed updated data indirectly. + \Drupal::getContainer()->get('search_api.tracking_helper') + ->trackReferencedEntityUpdate($entity); } /** * Implements hook_entity_delete(). * - * Deletes all entries for this entity from the tracking table for each index - * that tracks this entity type. - * - * By setting the $entity->search_api_skip_tracking property to a true-like - * value before this hook is invoked, you can prevent this behavior and make the - * Search API ignore this deletion. (Note that this might lead to stale data in - * the tracking table or on the server, since the item will not removed from - * there (if it has been added before).) - * * Note that this function implements tracking only on behalf of the "Content * Entity" datasource defined in this module, not for entity-based datasources * in general. Datasources defined by other modules still have to implement * their own mechanism for tracking new/updated/deleted entities. * - * @see \Drupal\search_api\Plugin\search_api\datasource\ContentEntity + * Independent of datasources, however, this will also call + * \Drupal\search_api\Utility\TrackingHelper::trackReferencedEntityUpdate() to + * attempt to mark all items for reindexing that indirectly indexed any fields + * of this entity. + * + * @see \Drupal\search_api\Plugin\search_api\datasource\ContentEntityTrackingManager::entityDelete() */ function search_api_entity_delete(EntityInterface $entity) { - // Check if the entity is a content entity. - if (!($entity instanceof ContentEntityInterface) || $entity->search_api_skip_tracking) { - return; - } - $indexes = ContentEntity::getIndexesForEntity($entity); - if (!$indexes) { - return; - } + // Call this hook on behalf of the Content Entity datasource. + \Drupal::getContainer()->get('search_api.entity_datasource.tracking_manager') + ->entityDelete($entity); - // Remove the search items for all the entity's translations. - $item_ids = []; - $entity_id = $entity->id(); - foreach (array_keys($entity->getTranslationLanguages()) as $langcode) { - $item_ids[] = $entity_id . ':' . $langcode; - } - $datasource_id = 'entity:' . $entity->getEntityTypeId(); - foreach ($indexes as $index) { - $index->trackItemsDeleted($datasource_id, $item_ids); - } + // Attempt to track all items as changed that indexed the entity indirectly. + \Drupal::getContainer()->get('search_api.tracking_helper') + ->trackReferencedEntityUpdate($entity, TRUE); } /** @@ -436,92 +354,11 @@ function search_api_theme() { * * Implemented on behalf of the "entity" datasource plugin. * - * @see \Drupal\search_api\Plugin\search_api\datasource\ContentEntity + * @see \Drupal\search_api\Plugin\search_api\datasource\ContentEntityTrackingManager::indexUpdate() */ function search_api_search_api_index_update(IndexInterface $index) { - if (!$index->status() || empty($index->original)) { - return; - } - /** @var \Drupal\search_api\IndexInterface $original */ - $original = $index->original; - if (!$original->status()) { - return; - } - - foreach ($index->getDatasources() as $datasource_id => $datasource) { - if ($datasource->getBaseId() != 'entity' - || !($datasource instanceof EntityDatasourceInterface) - || !$original->isValidDatasource($datasource_id)) { - continue; - } - $old_datasource = $original->getDatasource($datasource_id); - $old_config = $old_datasource->getConfiguration(); - $new_config = $datasource->getConfiguration(); - - if ($old_config != $new_config) { - // Bundles and languages share the same structure, so changes can be - // processed in a unified way. - $tasks = []; - $insert_task = ContentEntityTaskManager::INSERT_ITEMS_TASK_TYPE; - $delete_task = ContentEntityTaskManager::DELETE_ITEMS_TASK_TYPE; - $settings = []; - $entity_type = \Drupal::entityTypeManager() - ->getDefinition($datasource->getEntityTypeId()); - if ($entity_type->hasKey('bundle')) { - $settings['bundles'] = $datasource->getBundles(); - } - if ($entity_type->isTranslatable()) { - $settings['languages'] = \Drupal::languageManager()->getLanguages(); - } - - // Determine which bundles/languages have been newly selected or - // deselected and then assign them to the appropriate actions depending - // on the current "default" setting. - foreach ($settings as $setting => $all) { - $old_selected = array_flip($old_config[$setting]['selected']); - $new_selected = array_flip($new_config[$setting]['selected']); - - // First, check if the "default" setting changed and invert the checked - // items for the old config, so the following comparison makes sense. - if ($old_config[$setting]['default'] != $new_config[$setting]['default']) { - $old_selected = array_diff_key($all, $old_selected); - } - - $newly_selected = array_keys(array_diff_key($new_selected, $old_selected)); - $newly_unselected = array_keys(array_diff_key($old_selected, $new_selected)); - if ($new_config[$setting]['default']) { - $tasks[$insert_task][$setting] = $newly_unselected; - $tasks[$delete_task][$setting] = $newly_selected; - } - else { - $tasks[$insert_task][$setting] = $newly_selected; - $tasks[$delete_task][$setting] = $newly_unselected; - } - } - - // This will keep only those tasks where at least one of "bundles" or - // "languages" is non-empty. - $tasks = array_filter($tasks, 'array_filter'); - $task_manager = \Drupal::getContainer() - ->get('search_api.task_manager'); - foreach ($tasks as $task => $data) { - $data += [ - 'datasource' => $datasource_id, - 'page' => 0, - ]; - $task_manager->addTask($task, NULL, $index, $data); - } - - // If we added any new tasks, set a batch for them. (If we aren't in a - // form submission, this will just be ignored.) - if ($tasks) { - $task_manager->setTasksBatch([ - 'index_id' => $index->id(), - 'type' => array_keys($tasks), - ]); - } - } - } + \Drupal::getContainer()->get('search_api.entity_datasource.tracking_manager') + ->indexUpdate($index); } /** diff --git a/search_api.services.yml b/search_api.services.yml index c67add92..867a866d 100644 --- a/search_api.services.yml +++ b/search_api.services.yml @@ -38,15 +38,19 @@ services: class: Drupal\search_api\Tracker\TrackerPluginManager arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@event_dispatcher'] - search_api.datasource_task_manager: + search_api.data_type_helper: + class: Drupal\search_api\Utility\DataTypeHelper + arguments: ['@module_handler', '@event_dispatcher', '@plugin.manager.search_api.data_type'] + + search_api.entity_datasource.task_manager: class: Drupal\search_api\Plugin\search_api\datasource\ContentEntityTaskManager arguments: ['@search_api.task_manager', '@entity_type.manager'] tags: - { name: event_subscriber } - search_api.data_type_helper: - class: Drupal\search_api\Utility\DataTypeHelper - arguments: ['@module_handler', '@event_dispatcher', '@plugin.manager.search_api.data_type'] + search_api.entity_datasource.tracking_manager: + class: Drupal\search_api\Plugin\search_api\datasource\ContentEntityTrackingManager + arguments: ['@entity_type.manager', '@language_manager', '@search_api.task_manager'] search_api.fields_helper: class: Drupal\search_api\Utility\FieldsHelper @@ -82,6 +86,10 @@ services: class: Drupal\search_api\Task\TaskManager arguments: ['@entity_type.manager', '@event_dispatcher', '@string_translation', '@messenger'] + search_api.tracking_helper: + class: Drupal\search_api\Utility\TrackingHelper + arguments: ['@entity_type.manager', '@language_manager', '@event_dispatcher', '@search_api.fields_helper', '@cache.default'] + search_api.vbo_view_data_provider: class: Drupal\search_api\Contrib\ViewsBulkOperationsEventSubscriber tags: diff --git a/src/Datasource/DatasourceInterface.php b/src/Datasource/DatasourceInterface.php index 8534f462..0bc7346e 100644 --- a/src/Datasource/DatasourceInterface.php +++ b/src/Datasource/DatasourceInterface.php @@ -2,6 +2,7 @@ namespace Drupal\search_api\Datasource; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\TypedData\ComplexDataInterface; use Drupal\search_api\Plugin\IndexPluginInterface; @@ -244,6 +245,62 @@ public function getEntityTypeId(); */ public function getItemIds($page = NULL); + /** + * Determines whether this datasource can contain entity references. + * + * If this method returns TRUE, the Search API will attempt to mark items for + * reindexing if indexed data in entities referenced by those items changes, + * using the datasource property information and the + * getAffectedItemsForEntityChange() method. + * + * @return bool + * TRUE if this datasource can contain entity references, FALSE otherwise. + * + * @see \Drupal\search_api\Datasource\DatasourceInterface::getAffectedItemsForEntityChange() + * @see \Drupal\search_api\Utility\TrackingHelper::trackReferencedEntityUpdate() + */ + public function canContainEntityReferences(): bool; + + /** + * Identifies items affected by a change to a referenced entity. + * + * A "change" in this context means an entity getting updated or deleted. (It + * won't get called for entities being inserted, as new entities cannot + * already have references pointing to them.) + * + * This method usually doesn't have to return the specified entity itself, + * even if it is part of this datasource. This method should instead only be + * used to detect items that are indirectly affected by this change. + * + * For instance, if an index contains nodes, and nodes can contain tags (which + * are taxonomy term references), and the search index contains the name of + * the tags as one of its fields, then a change of a term name should result + * in all nodes being reindexed that contain that term as a tag. So, the item + * IDs of those nodes should be returned by this method (in case this + * datasource contains them). + * + * This method will only be called if this datasource plugin returns TRUE in + * canContainEntityReferences(). + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity that just got changed. + * @param array[] $foreign_entity_relationship_map + * Map of known entity relationships that exist in the index. Its structure + * is identical to the return value of the + * \Drupal\search_api\Utility\TrackingHelper::getForeignEntityRelationsMap() + * method. + * @param \Drupal\Core\Entity\EntityInterface|null $original_entity + * (optional) The original entity before the change. If this argument is + * NULL, it means the entity got deleted. + * + * @return string[] + * Array of item IDs that are affected by the changes between $entity and + * $original_entity entities. + * + * @see \Drupal\search_api\Datasource\DatasourceInterface::canContainEntityReferences() + */ + public function getAffectedItemsForEntityChange(EntityInterface $entity, array $foreign_entity_relationship_map, EntityInterface $original_entity = NULL): array; + /** * Retrieves any dependencies of the given fields. * diff --git a/src/Datasource/DatasourcePluginBase.php b/src/Datasource/DatasourcePluginBase.php index 1de0c211..ffdffc30 100644 --- a/src/Datasource/DatasourcePluginBase.php +++ b/src/Datasource/DatasourcePluginBase.php @@ -3,6 +3,7 @@ namespace Drupal\search_api\Datasource; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Language\Language; use Drupal\Core\Session\AccountInterface; use Drupal\Core\TypedData\ComplexDataInterface; @@ -157,6 +158,20 @@ public function getItemIds($page = NULL) { return NULL; } + /** + * {@inheritdoc} + */ + public function canContainEntityReferences(): bool { + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function getAffectedItemsForEntityChange(EntityInterface $entity, array $foreign_entity_relationship_map, EntityInterface $original_entity = NULL): array { + return []; + } + /** * {@inheritdoc} */ diff --git a/src/Event/MappingForeignRelationshipsEvent.php b/src/Event/MappingForeignRelationshipsEvent.php new file mode 100644 index 00000000..d700ac6c --- /dev/null +++ b/src/Event/MappingForeignRelationshipsEvent.php @@ -0,0 +1,93 @@ +index = $index; + $this->foreignRelationshipsMapping = &$foreign_relationships_mapping; + $this->cacheability = $cacheability; + } + + /** + * Retrieves the index whose foreign relationships are mapped. + * + * @return \Drupal\search_api\IndexInterface + * The index whose foreign relationships are mapped. + */ + public function getIndex(): IndexInterface { + return $this->index; + } + + /** + * Retrieves a reference to the foreign relationships mapping. + * + * @return array[] + * A (numerically keyed) array of foreign relationship mappings. Each + * sub-array here represents a single known relationship. Such sub-arrays + * will have the following structure: + * - entity_type: (string) Entity type that is referred to from the index. + * - bundles: (array) Optional array of particular entity bundles that are + * referred to from the index. Empty array here means index refers to + * all the bundles. + * - property_path_to_foreign_entity: (string) Property path where the index + * refers to this entity. + * - field_name: (string) Name of the field on the referenced entity that + * actively participates in the search index. + */ + public function &getForeignRelationshipsMapping(): array { + return $this->foreignRelationshipsMapping; + } + + /** + * Retrieves cacheability associated with the foreign relationships mapping. + * + * @return \Drupal\Core\Cache\RefinableCacheableDependencyInterface + * Cacheability associated with the foreign relationships mapping. + */ + public function getCacheability(): RefinableCacheableDependencyInterface { + return $this->cacheability; + } + +} diff --git a/src/Event/SearchApiEvents.php b/src/Event/SearchApiEvents.php index d955fbca..9a13c183 100644 --- a/src/Event/SearchApiEvents.php +++ b/src/Event/SearchApiEvents.php @@ -124,6 +124,21 @@ final class SearchApiEvents { */ const MAPPING_FIELD_TYPES = 'search_api.mapping_field_types'; + /** + * The name of the event fired when mapping foreign relationships of an index. + * + * Foreign relationships of an index help Search API to mark for reindexing + * search items affected by changes to entities that are indirectly indexed. + * + * This event can be leveraged to alter the map of foreign + * relationships discovered for any particular search index. + * + * @Event + * + * @see \Drupal\search_api\Event\MappingForeignRelationshipsEvent + */ + const MAPPING_FOREIGN_RELATIONSHIPS = 'search_api.mapping_foreign_relationships'; + /** * The name of the event fired when building a map of Views field handlers. * diff --git a/src/IndexInterface.php b/src/IndexInterface.php index 7eeb4247..f2a23515 100644 --- a/src/IndexInterface.php +++ b/src/IndexInterface.php @@ -30,6 +30,19 @@ interface IndexInterface extends ConfigEntityInterface { */ const DATASOURCE_ID_SEPARATOR = '/'; + /** + * String used to separate individual properties within a property path. + * + * Property paths are used throughout the Search API to reference properties + * that are not (necessarily) present directly on some entity or item, but + * could be nested in some referenced entity/item, even over multiple levels. + * Properties in such a path are separated by this value. + * + * An example for a property path would be "field_tags:entity:name" for the + * name of the associated tag(s) of an entity. + */ + const PROPERTY_PATH_SEPARATOR = ':'; + /** * Retrieves the index description. * diff --git a/src/Plugin/search_api/datasource/ContentEntity.php b/src/Plugin/search_api/datasource/ContentEntity.php index 9b2c13b5..b6ff741f 100644 --- a/src/Plugin/search_api/datasource/ContentEntity.php +++ b/src/Plugin/search_api/datasource/ContentEntity.php @@ -27,9 +27,8 @@ use Drupal\field\FieldConfigInterface; use Drupal\field\FieldStorageConfigInterface; use Drupal\search_api\Datasource\DatasourcePluginBase; -use Drupal\search_api\Entity\Index; -use Drupal\search_api\Plugin\PluginFormTrait; use Drupal\search_api\IndexInterface; +use Drupal\search_api\Plugin\PluginFormTrait; use Drupal\search_api\Utility\FieldsHelperInterface; use Drupal\search_api\Utility\Utility; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -1021,6 +1020,71 @@ public function getFieldDependencies(array $fields) { return $dependencies; } + /** + * {@inheritdoc} + */ + public function canContainEntityReferences(): bool { + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function getAffectedItemsForEntityChange(EntityInterface $entity, array $foreign_entity_relationship_map, EntityInterface $original_entity = NULL): array { + if (!($entity instanceof ContentEntityInterface)) { + return []; + } + + $ids_to_reindex = []; + foreach ($foreign_entity_relationship_map as $relation_info) { + // Check whether entity type and (if specified) bundles match the entity. + if ($relation_info['entity_type'] !== $entity->getEntityTypeId()) { + continue; + } + if (!empty($relation_info['bundles']) + && !in_array($entity->bundle(), $relation_info['bundles'])) { + continue; + } + // Maybe this entity belongs to a bundle that does not have this field + // attached. Hence we have this check to ensure the field is present on + // this particular entity. + if (!$entity->hasField($relation_info['field_name'])) { + continue; + } + + $items = $entity->get($relation_info['field_name']); + + // We trigger re-indexing if either it is a removed entity or the + // entity has changed its field value (in case it's an update). + if (!$original_entity || !$items->equals($original_entity->get($relation_info['field_name']))) { + $query = $this->entityTypeManager->getStorage($this->getEntityTypeId()) + ->getQuery(); + $query->accessCheck(FALSE); + + // Luckily, to translate from property path to the entity query + // condition syntax all we have to do is replace the property + // path separator with the entity query path separator (a dot) and + // that's it. + $query->condition(str_replace(IndexInterface::PROPERTY_PATH_SEPARATOR, '.', $relation_info['property_path_to_foreign_entity']), $entity->id()); + + try { + $ids_to_reindex = array_values($query->execute()); + } + catch (\Exception $e) { + continue; + } + foreach ($ids_to_reindex as $id_to_reindex) { + foreach ($this->getLanguages() as $language) { + $language = $language->getId(); + $ids_to_reindex["$id_to_reindex:$language"] = 1; + } + } + } + } + + return array_keys($ids_to_reindex); + } + /** * Computes all dependencies of the given property path. * @@ -1085,79 +1149,9 @@ protected function getPropertyPathDependencies($property_path, array $properties * {@inheritdoc} */ public static function getIndexesForEntity(ContentEntityInterface $entity) { - $datasource_id = 'entity:' . $entity->getEntityTypeId(); - $entity_bundle = $entity->bundle(); - $has_bundles = $entity->getEntityType()->hasKey('bundle'); - - // Needed for PhpStorm. See https://youtrack.jetbrains.com/issue/WI-23395. - /** @var \Drupal\search_api\IndexInterface[] $indexes */ - $indexes = Index::loadMultiple(); - - foreach ($indexes as $index_id => $index) { - // Filter out indexes that don't contain the datasource in question. - if (!$index->isValidDatasource($datasource_id)) { - unset($indexes[$index_id]); - } - elseif ($has_bundles) { - // If the entity type supports bundles, we also have to filter out - // indexes that exclude the entity's bundle. - $config = $index->getDatasource($datasource_id)->getConfiguration(); - if (!Utility::matches($entity_bundle, $config['bundles'])) { - unset($indexes[$index_id]); - } - } - } - - return $indexes; - } - - /** - * Filters a set of datasource-specific item IDs. - * - * Returns only those item IDs that are valid for the given datasource and - * index. This method only checks the item language, though – whether an - * entity with that ID actually exists, or whether it has a bundle included - * for that datasource, is not verified. - * - * @param \Drupal\search_api\IndexInterface $index - * The index for which to validate. - * @param string $datasource_id - * The ID of the datasource on the index for which to validate. - * @param string[] $item_ids - * The item IDs to be validated. - * - * @return string[] - * All given item IDs that are valid for that index and datasource. - */ - public static function filterValidItemIds(IndexInterface $index, $datasource_id, array $item_ids) { - if (!$index->isValidDatasource($datasource_id)) { - return $item_ids; - } - $config = $index->getDatasource($datasource_id)->getConfiguration(); - // If the entity type doesn't allow translations, we just accept all IDs. - // (If the entity type were translatable, the config key would have been set - // with the default configuration.) - if (!isset($config['languages']['selected'])) { - return $item_ids; - } - $always_valid = [ - LanguageInterface::LANGCODE_NOT_SPECIFIED, - LanguageInterface::LANGCODE_NOT_APPLICABLE, - ]; - $valid_ids = []; - foreach ($item_ids as $item_id) { - $pos = strrpos($item_id, ':'); - // Item IDs without colons are always invalid. - if ($pos === FALSE) { - continue; - } - $langcode = substr($item_id, $pos + 1); - if (Utility::matches($langcode, $config['languages']) - || in_array($langcode, $always_valid)) { - $valid_ids[] = $item_id; - } - } - return $valid_ids; + return \Drupal::getContainer() + ->get('search_api.entity_datasource.tracking_manager') + ->getIndexesForEntity($entity); } /** diff --git a/src/Plugin/search_api/datasource/ContentEntityTrackingManager.php b/src/Plugin/search_api/datasource/ContentEntityTrackingManager.php new file mode 100644 index 00000000..688e78be --- /dev/null +++ b/src/Plugin/search_api/datasource/ContentEntityTrackingManager.php @@ -0,0 +1,423 @@ +entityTypeManager = $entityTypeManager; + $this->languageManager = $languageManager; + $this->taskManager = $taskManager; + } + + /** + * Implements hook_entity_insert(). + * + * Adds entries for all languages of the new entity to the tracking table for + * each index that tracks entities of this type. + * + * By setting the $entity->search_api_skip_tracking property to a true-like + * value before this hook is invoked, you can prevent this behavior and make the + * Search API ignore this new entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The new entity. + * + * @see search_api_entity_insert() + */ + public function entityInsert(EntityInterface $entity) { + // Check if the entity is a content entity. + if (!($entity instanceof ContentEntityInterface) || $entity->search_api_skip_tracking) { + return; + } + $indexes = $this->getIndexesForEntity($entity); + if (!$indexes) { + return; + } + + // Compute the item IDs for all languages of the entity. + $item_ids = []; + $entity_id = $entity->id(); + foreach (array_keys($entity->getTranslationLanguages()) as $langcode) { + $item_ids[] = $entity_id . ':' . $langcode; + } + $datasource_id = 'entity:' . $entity->getEntityTypeId(); + foreach ($indexes as $index) { + $filtered_item_ids = $this->filterValidItemIds($index, $datasource_id, $item_ids); + $index->trackItemsInserted($datasource_id, $filtered_item_ids); + } + } + + /** + * Implements hook_entity_update(). + * + * Updates the corresponding tracking table entries for each index that tracks + * this entity. + * + * Also takes care of new or deleted translations. + * + * By setting the $entity->search_api_skip_tracking property to a true-like + * value before this hook is invoked, you can prevent this behavior and make the + * Search API ignore this update. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The updated entity. + * + * @see search_api_entity_update() + */ + public function entityUpdate(EntityInterface $entity) { + // Check if the entity is a content entity. + if (!($entity instanceof ContentEntityInterface) || $entity->search_api_skip_tracking) { + return; + } + + $indexes = $this->getIndexesForEntity($entity); + if (!$indexes) { + return; + } + + // Compare old and new languages for the entity to identify inserted, + // updated and deleted translations (and, therefore, search items). + $entity_id = $entity->id(); + $inserted_item_ids = []; + $updated_item_ids = $entity->getTranslationLanguages(); + $deleted_item_ids = []; + $old_translations = $entity->original->getTranslationLanguages(); + foreach ($old_translations as $langcode => $language) { + if (!isset($updated_item_ids[$langcode])) { + $deleted_item_ids[] = $langcode; + } + } + foreach ($updated_item_ids as $langcode => $language) { + if (!isset($old_translations[$langcode])) { + unset($updated_item_ids[$langcode]); + $inserted_item_ids[] = $langcode; + } + } + + $datasource_id = 'entity:' . $entity->getEntityTypeId(); + $combine_id = function ($langcode) use ($entity_id) { + return $entity_id . ':' . $langcode; + }; + $inserted_item_ids = array_map($combine_id, $inserted_item_ids); + $updated_item_ids = array_map($combine_id, array_keys($updated_item_ids)); + $deleted_item_ids = array_map($combine_id, $deleted_item_ids); + foreach ($indexes as $index) { + if ($inserted_item_ids) { + $filtered_item_ids = $this->filterValidItemIds($index, $datasource_id, $inserted_item_ids); + $index->trackItemsInserted($datasource_id, $filtered_item_ids); + } + if ($updated_item_ids) { + $index->trackItemsUpdated($datasource_id, $updated_item_ids); + } + if ($deleted_item_ids) { + $index->trackItemsDeleted($datasource_id, $deleted_item_ids); + } + } + } + + /** + * Implements hook_entity_delete(). + * + * Deletes all entries for this entity from the tracking table for each index + * that tracks this entity type. + * + * By setting the $entity->search_api_skip_tracking property to a true-like + * value before this hook is invoked, you can prevent this behavior and make the + * Search API ignore this deletion. (Note that this might lead to stale data in + * the tracking table or on the server, since the item will not removed from + * there (if it has been added before).) + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The deleted entity. + * + * @see search_api_entity_delete() + */ + public function entityDelete(EntityInterface $entity) { + // Check if the entity is a content entity. + if (!($entity instanceof ContentEntityInterface) || $entity->search_api_skip_tracking) { + return; + } + + $indexes = $this->getIndexesForEntity($entity); + if (!$indexes) { + return; + } + + // Remove the search items for all the entity's translations. + $item_ids = []; + $entity_id = $entity->id(); + foreach (array_keys($entity->getTranslationLanguages()) as $langcode) { + $item_ids[] = $entity_id . ':' . $langcode; + } + $datasource_id = 'entity:' . $entity->getEntityTypeId(); + foreach ($indexes as $index) { + $index->trackItemsDeleted($datasource_id, $item_ids); + } + } + + /** + * Retrieves all indexes that are configured to index the given entity. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity for which to check. + * + * @return \Drupal\search_api\IndexInterface[] + * All indexes that are configured to index the given entity (using the + * default Content Entity datasource plugin). + */ + public function getIndexesForEntity(ContentEntityInterface $entity): array { + // @todo This is called for every single entity insert, update or deletion + // on the whole site. Should maybe be cached? + $datasource_id = 'entity:' . $entity->getEntityTypeId(); + $entity_bundle = $entity->bundle(); + $has_bundles = $entity->getEntityType()->hasKey('bundle'); + + /** @var \Drupal\search_api\IndexInterface[] $indexes */ + $indexes = []; + try { + $indexes = $this->entityTypeManager->getStorage('search_api_index') + ->loadMultiple(); + } + // @todo Replace with multi-catch once we depend on PHP 7.1+. + catch (InvalidPluginDefinitionException $e) { + // Can't really happen, but play it safe to appease static code analysis. + } + catch (PluginNotFoundException $e) { + // Can't really happen, but play it safe to appease static code analysis. + } + + foreach ($indexes as $index_id => $index) { + // Filter out indexes that don't contain the datasource in question. + if (!$index->isValidDatasource($datasource_id)) { + unset($indexes[$index_id]); + } + elseif ($has_bundles) { + // If the entity type supports bundles, we also have to filter out + // indexes that exclude the entity's bundle. + try { + $config = $index->getDatasource($datasource_id)->getConfiguration(); + } + catch (SearchApiException $e) { + // Can't really happen, but play it safe to appease static code + // analysis. + unset($indexes[$index_id]); + continue; + } + if (!Utility::matches($entity_bundle, $config['bundles'])) { + unset($indexes[$index_id]); + } + } + } + + return $indexes; + } + + /** + * Implements hook_ENTITY_TYPE_update() for type "search_api_index". + * + * Detects changes in the selected bundles or languages and adds/removes items + * to/from tracking accordingly. + * + * @param \Drupal\search_api\IndexInterface $index + * The index that was updated. + * + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * Thrown if a datasource referenced an unknown entity type. + * @throws \Drupal\search_api\SearchApiException + * Never thrown, but static analysis tools think it could be. + * + * @see search_api_search_api_index_update() + */ + public function indexUpdate(IndexInterface $index) { + if (!$index->status()) { + return; + } + /** @var \Drupal\search_api\IndexInterface $original */ + $original = $index->original ?? NULL; + if (!$original || !$original->status()) { + return; + } + + foreach ($index->getDatasources() as $datasource_id => $datasource) { + if ($datasource->getBaseId() != 'entity' + || !($datasource instanceof EntityDatasourceInterface) + || !$original->isValidDatasource($datasource_id)) { + continue; + } + $old_datasource = $original->getDatasource($datasource_id); + $old_config = $old_datasource->getConfiguration(); + $new_config = $datasource->getConfiguration(); + + if ($old_config != $new_config) { + // Bundles and languages share the same structure, so changes can be + // processed in a unified way. + $tasks = []; + $insert_task = ContentEntityTaskManager::INSERT_ITEMS_TASK_TYPE; + $delete_task = ContentEntityTaskManager::DELETE_ITEMS_TASK_TYPE; + $settings = []; + $entity_type = $this->entityTypeManager + ->getDefinition($datasource->getEntityTypeId()); + if ($entity_type->hasKey('bundle')) { + $settings['bundles'] = $datasource->getBundles(); + } + if ($entity_type->isTranslatable()) { + $settings['languages'] = $this->languageManager->getLanguages(); + } + + // Determine which bundles/languages have been newly selected or + // deselected and then assign them to the appropriate actions depending + // on the current "default" setting. + foreach ($settings as $setting => $all) { + $old_selected = array_flip($old_config[$setting]['selected']); + $new_selected = array_flip($new_config[$setting]['selected']); + + // First, check if the "default" setting changed and invert the checked + // items for the old config, so the following comparison makes sense. + if ($old_config[$setting]['default'] != $new_config[$setting]['default']) { + $old_selected = array_diff_key($all, $old_selected); + } + + $newly_selected = array_keys(array_diff_key($new_selected, $old_selected)); + $newly_unselected = array_keys(array_diff_key($old_selected, $new_selected)); + if ($new_config[$setting]['default']) { + $tasks[$insert_task][$setting] = $newly_unselected; + $tasks[$delete_task][$setting] = $newly_selected; + } + else { + $tasks[$insert_task][$setting] = $newly_selected; + $tasks[$delete_task][$setting] = $newly_unselected; + } + } + + // This will keep only those tasks where at least one of "bundles" or + // "languages" is non-empty. + $tasks = array_filter($tasks, 'array_filter'); + foreach ($tasks as $task => $data) { + $data += [ + 'datasource' => $datasource_id, + 'page' => 0, + ]; + $this->taskManager->addTask($task, NULL, $index, $data); + } + + // If we added any new tasks, set a batch for them. (If we aren't in a + // form submission, this will just be ignored.) + if ($tasks) { + $this->taskManager->setTasksBatch([ + 'index_id' => $index->id(), + 'type' => array_keys($tasks), + ]); + } + } + } + } + + /** + * Filters a set of datasource-specific item IDs. + * + * Returns only those item IDs that are valid for the given datasource and + * index. This method only checks the item language, though – whether an + * entity with that ID actually exists, or whether it has a bundle included + * for that datasource, is not verified. + * + * @param \Drupal\search_api\IndexInterface $index + * The index for which to validate. + * @param string $datasource_id + * The ID of the datasource on the index for which to validate. + * @param string[] $item_ids + * The item IDs to be validated. + * + * @return string[] + * All given item IDs that are valid for that index and datasource. + */ + protected function filterValidItemIds(IndexInterface $index, string $datasource_id, array $item_ids): array { + if (!$index->isValidDatasource($datasource_id)) { + return $item_ids; + } + + try { + $config = $index->getDatasource($datasource_id)->getConfiguration(); + } + catch (SearchApiException $e) { + // Can't really happen, but play it safe to appease static code analysis. + return $item_ids; + } + + // If the entity type doesn't allow translations, we just accept all IDs. + // (If the entity type were translatable, the config key would have been set + // with the default configuration.) + if (!isset($config['languages']['selected'])) { + return $item_ids; + } + $always_valid = [ + LanguageInterface::LANGCODE_NOT_SPECIFIED, + LanguageInterface::LANGCODE_NOT_APPLICABLE, + ]; + $valid_ids = []; + foreach ($item_ids as $item_id) { + $pos = strrpos($item_id, ':'); + // Item IDs without colons are always invalid. + if ($pos === FALSE) { + continue; + } + $langcode = substr($item_id, $pos + 1); + if (Utility::matches($langcode, $config['languages']) + || in_array($langcode, $always_valid)) { + $valid_ids[] = $item_id; + } + } + return $valid_ids; + } + +} diff --git a/src/Utility/TrackingHelper.php b/src/Utility/TrackingHelper.php new file mode 100644 index 00000000..570ee378 --- /dev/null +++ b/src/Utility/TrackingHelper.php @@ -0,0 +1,281 @@ +languageManager = $languageManager; + $this->entityTypeManager = $entityTypeManager; + $this->eventDispatcher = $eventDispatcher; + $this->fieldsHelper = $fieldsHelper; + $this->cache = $cache; + } + + /** + * {@inheritdoc} + */ + public function trackReferencedEntityUpdate(EntityInterface $entity, bool $deleted = FALSE) { + /** @var \Drupal\search_api\IndexInterface[] $indexes */ + $indexes = []; + try { + $indexes = $this->entityTypeManager->getStorage('search_api_index') + ->loadMultiple(); + } + // @todo Replace with multi-catch once we depend on PHP 7.1+. + catch (InvalidPluginDefinitionException $e) { + // Can't really happen, but play it safe to appease static code analysis. + } + catch (PluginNotFoundException $e) { + // Can't really happen, but play it safe to appease static code analysis. + } + + // Map of foreign entity relations. Will get lazily populated as soon as we + // actually need it. + $original = $deleted ? NULL : $entity->original ?? NULL; + foreach ($indexes as $index) { + $map = NULL; + foreach ($index->getDatasources() as $datasource_id => $datasource) { + if (!$datasource->canContainEntityReferences()) { + continue; + } + + if ($map === NULL) { + $map = $this->getForeignEntityRelationsMap($index); + // If there are no foreign entities in the index, no need to continue. + if (!$map) { + break 1; + } + } + + $item_ids = $datasource->getAffectedItemsForEntityChange($entity, $map, $original); + if (!empty($item_ids)) { + $index->trackItemsUpdated($datasource_id, $item_ids); + } + } + } + } + + /** + * Analyzes the index fields and constructs a map of entity references. + * + * This map tries to record all ways that entities' values are indirectly + * indexed by the given index. (That is, what items' indexed contents might be + * affected by a given entity being updated or deleted.) + * + * @param \Drupal\search_api\IndexInterface $index + * The index for which to create the map. + * + * @return array[] + * A (numerically keyed) array of foreign relationship mappings. Each + * sub-array represents a single known relationship. Such sub-arrays will + * have the following structure: + * - entity_type: (string) The entity type that is referenced from the + * index. + * - bundles: (string[]) An optional array of particular entity bundles that + * are referred to from the index. An empty array here means that the + * index refers to all the bundles. + * - property_path_to_foreign_entity: (string) Property path where the index + * refers to this entity. + * - field_name: (string) Name of the field on the referenced entity that is + * indexed in the search index. + */ + protected function getForeignEntityRelationsMap(IndexInterface $index): array { + $cid = "search_api:{$index->id()}:foreign_entities_relations_map"; + + $cache = $this->cache->get($cid); + if ($cache) { + return $cache->data; + } + + $cacheability = new CacheableMetadata(); + $cacheability->addCacheableDependency($index); + $data = []; + foreach ($index->getFields() as $field) { + try { + $datasource = $field->getDatasource(); + } + catch (SearchApiException $e) { + continue; + } + if (!$datasource) { + continue; + } + + $relation_info = [ + 'entity_type' => NULL, + 'bundles' => NULL, + 'property_path_to_foreign_entity' => NULL, + ]; + $seen_path_chunks = []; + $property_definitions = $datasource->getPropertyDefinitions(); + $field_property = Utility::splitPropertyPath($field->getPropertyPath(), FALSE); + for (; $field_property[0]; $field_property = Utility::splitPropertyPath($field_property[1], FALSE)) { + $property_definition = $this->fieldsHelper->retrieveNestedProperty($property_definitions, $field_property[0]); + if (!$property_definition) { + // Seems like we could not map it from the property path to some Typed + // Data definition. In the absence of a better alternative, let's + // simply disregard this field. + break; + } + + $seen_path_chunks[] = $field_property[0]; + + if ($property_definition instanceof FieldItemDataDefinitionInterface && $property_definition->getFieldDefinition() + ->isComputed()) { + // We cannot really deal with computed fields since we have no + // knowledge about their internal logic. Thus we cannot process + // this field any further. + break; + } + + if ($relation_info['entity_type'] && $property_definition instanceof FieldItemDataDefinitionInterface) { + // Parent is an entity. Hence this level is fields of the entity. + $cacheability->addCacheableDependency($property_definition->getFieldDefinition()); + + $data[] = $relation_info + [ + 'field_name' => $property_definition->getFieldDefinition() + ->getName(), + ]; + } + + $entity_reference = $this->isEntityReferenceDataDefinition($property_definition, $cacheability); + if ($entity_reference + && $relation_info['entity_type'] !== $entity_reference['entity_type']) { + $relation_info = $entity_reference; + $relation_info['property_path_to_foreign_entity'] = implode(IndexInterface::PROPERTY_PATH_SEPARATOR, $seen_path_chunks); + } + + if ($property_definition instanceof ComplexDataDefinitionInterface) { + $property_definitions = $this->fieldsHelper->getNestedProperties($property_definition); + } + else { + // This item no longer has "nested" properties in its Typed Data + // definition. Thus we cannot examine it any further than the current + // point. + break; + } + } + } + + // Let other modules alter this information, potentially adding more + // relationships. + $event = new MappingForeignRelationshipsEvent($index, $data, $cacheability); + $this->eventDispatcher->dispatch(SearchApiEvents::MAPPING_FOREIGN_RELATIONSHIPS, $event); + + $this->cache->set($cid, $data, $cacheability->getCacheMaxAge(), $cacheability->getCacheTags()); + + return $data; + } + + /** + * Determines whether the given property is a reference to an entity. + * + * @param \Drupal\Core\TypedData\DataDefinitionInterface $property_definition + * The property to test. + * @param \Drupal\Core\Cache\RefinableCacheableDependencyInterface $cacheability + * A cache metadata object to track any caching information necessary in + * this method call. + * + * @return array + * This method will return an empty array if $property is not an entity + * reference. Otherwise it will return an associative array with the + * following structure: + * - entity_type: (string) The entity type to which $property refers. + * - bundles: (array) A list of bundles to which $property refers. In case + * specific bundles cannot be determined or the $property points to all + * the bundles, this key will contain an empty array. + */ + protected function isEntityReferenceDataDefinition(DataDefinitionInterface $property_definition, RefinableCacheableDependencyInterface $cacheability): array { + $return = []; + + if ($property_definition instanceof FieldItemDataDefinitionInterface + && $property_definition->getFieldDefinition()->getType() === 'entity_reference') { + $field = $property_definition->getFieldDefinition(); + $cacheability->addCacheableDependency($field); + + $return['entity_type'] = $field->getSetting('target_type'); + $field_settings = $field->getSetting('handler_settings'); + $return['bundles'] = $field_settings['target_bundles'] ?? []; + } + elseif ($property_definition instanceof EntityDataDefinitionInterface) { + $return['entity_type'] = $property_definition->getEntityTypeId(); + $return['bundles'] = $property_definition->getBundles() ?: []; + } + + return $return; + } + +} diff --git a/src/Utility/TrackingHelperInterface.php b/src/Utility/TrackingHelperInterface.php new file mode 100644 index 00000000..a7df0cd4 --- /dev/null +++ b/src/Utility/TrackingHelperInterface.php @@ -0,0 +1,29 @@ +installSchema('search_api', ['search_api_item']); + $this->installSchema('node', ['node_access']); + $this->installEntitySchema('user'); + $this->installEntitySchema('node'); + $this->installEntitySchema('search_api_task'); + $this->installConfig([ + 'search_api', + 'search_api_test_example_content_references', + ]); + + // Do not use a batch for tracking the initial items after creating an + // index when running the tests via the GUI. Otherwise, it seems Drupal's + // Batch API gets confused and the test fails. + if (!Utility::isRunningInCli()) { + \Drupal::state()->set('search_api_use_tracking_batch', FALSE); + } + + Server::create([ + 'id' => 'server', + 'backend' => 'search_api_test', + ])->save(); + $this->index = Index::create([ + 'id' => 'index', + 'tracker_settings' => [ + 'default' => [], + ], + 'datasource_settings' => [ + 'entity:node' => [ + 'bundles' => [ + 'default' => FALSE, + 'selected' => ['parent'], + ], + ], + ], + 'server' => 'server', + 'field_settings' => [ + 'child_indexed' => [ + 'label' => 'Child > Indexed', + 'datasource_id' => 'entity:node', + 'property_path' => 'entity_reference:entity:indexed', + 'type' => 'text', + ], + ], + ]); + + $this->index->save(); + } + + /** + * Tests correct tracking of changes in referenced entities. + * + * @param array $parent_map + * Map of parent nodes to create. It should be compatible with the + * ::createEntitiesFromMap() method. + * @param array $child_map + * Map of the child nodes to create. It should be compatible with the + * ::createEntitiesFromMap(). + * @param array $updates + * A list of updates to child entities to execute. It should be keyed by the + * machine-name of the child entities. Value can be either FALSE (to remove + * an entity) or a list of the new raw values to apply to the entity. + * @param array $expected + * A list of search items that should be marked for reindexing. + * + * @dataProvider referencedEntityChangedDataProvider + */ + public function testReferencedEntityChanged(array $parent_map, array $child_map, array $updates, array $expected) { + $children = $this->createEntitiesFromMap($child_map, [], 'child'); + $this->createEntitiesFromMap($parent_map, $children, 'parent'); + + $this->index->indexItems(); + $tracker = $this->index->getTrackerInstance(); + $this->assertEquals([], $tracker->getRemainingItems()); + + // Now let's execute updates. + foreach ($updates as $i => $field_updates) { + if ($field_updates === FALSE) { + $children[$i]->delete(); + } + else { + foreach ($field_updates as $field => $items) { + $children[$i]->get($field)->setValue($items); + } + + $children[$i]->save(); + } + } + + $this->assertEquals($expected, $tracker->getRemainingItems()); + } + + /** + * Provides test data for testReferencedEntityChanged(). + * + * @return array[] + * An array of argument arrays for testReferencedEntityChanged(). + * + * @see \Drupal\Tests\search_api\Kernel\ReferencedEntitiesReindexingTest::testReferencedEntityChanged() + */ + public function referencedEntityChangedDataProvider(): array { + $tests = []; + + $parent_map = [ + 'parent' => [ + 'title' => 'Parent', + 'entity_reference' => 'child', + ], + ]; + + $parent_expected = ['entity:node/3:en']; + + $child_variants = ['child', 'unrelated']; + $field_variants = ['indexed', 'not_indexed']; + + foreach ($child_variants as $child) { + foreach ($field_variants as $field) { + if ($child == 'child' && $field == 'indexed') { + // This is how Search API represents our "parent" node in its tracking + // data. + $expected = $parent_expected; + } + else { + $expected = []; + } + + $tests["changing value of $field field within the $child entity"] = [ + $parent_map, + [ + 'child' => [ + 'title' => 'Child', + 'indexed' => 'Original indexed value', + 'not_indexed' => 'Original not indexed value.', + ], + 'unrelated' => [ + 'title' => 'Unrelated child', + 'indexed' => 'Original indexed value', + 'not_indexed' => 'Original not indexed value.', + ], + ], + [ + $child => [ + $field => "New $field value.", + ], + ], + $expected, + ]; + + $tests["appending value of $field field within the $child entity"] = [ + $parent_map, + [ + 'child' => [ + 'title' => 'Child', + 'indexed' => 'Original indexed value', + ], + 'unrelated' => [ + 'title' => 'Unrelated child', + 'indexed' => 'Original indexed value', + ], + ], + [ + $child => [ + $field => "New $field value.", + ], + ], + $expected, + ]; + + $tests["removing value of $field field within the $child entity"] = [ + $parent_map, + [ + 'child' => [ + 'title' => 'Child', + 'indexed' => 'Original indexed value', + 'not_indexed' => 'Original not indexed value.', + ], + 'unrelated' => [ + 'title' => 'Unrelated child', + 'indexed' => 'Original indexed value', + 'not_indexed' => 'Original not indexed value.', + ], + ], + [ + $child => [ + $field => [], + ], + ], + $expected, + ]; + } + + $tests["removing the $child entity"] = [ + $parent_map, + [ + 'child' => [ + 'title' => 'Child', + 'indexed' => 'Original indexed value', + 'not_indexed' => 'Original not indexed value.', + ], + 'unrelated' => [ + 'title' => 'Unrelated child', + 'indexed' => 'Original indexed value', + 'not_indexed' => 'Original not indexed value.', + ], + ], + [ + $child => FALSE, + ], + $child == 'child' ? $parent_expected : [], + ]; + } + + return $tests; + } + + /** + * Creates a list of entities with the given fields. + * + * @param array[] $entity_fields + * Map of entities to create. It should be keyed by a machine-friendly name. + * Values of this map should be sub-arrays that represent raw values to + * supply into the entity's fields when creating it. + * @param \Drupal\Core\Entity\ContentEntityInterface[] $references_map + * There is a magical field "entity_reference" in the $map input argument. + * Values of this field should reference some other entity. This "other" + * entity will be looked up by the key in this references map. This way you + * can create entity reference data without knowing the entity IDs ahead of + * time. + * @param string $bundle + * Bundle to utilize when creating entities from the $map array. + * + * @return \Drupal\Core\Entity\ContentEntityInterface[] + * Entities created according to the supplied $map array. This array will be + * keyed by the same machine-names as the input $map argument. + */ + protected function createEntitiesFromMap(array $entity_fields, array $references_map, string $bundle): array { + $entities = []; + + foreach ($entity_fields as $i => $fields) { + if (isset($fields['entity_reference'])) { + $fields['entity_reference'] = $references_map[$fields['entity_reference']]->id(); + } + $fields['type'] = $bundle; + $entities[$i] = Node::create($fields); + $entities[$i]->save(); + } + + return $entities; + } + +} diff --git a/tests/src/Kernel/Views/ViewsCacheabilityMetadataExportTest.php b/tests/src/Kernel/Views/ViewsCacheabilityMetadataExportTest.php index 7d6c91bf..89de60f5 100644 --- a/tests/src/Kernel/Views/ViewsCacheabilityMetadataExportTest.php +++ b/tests/src/Kernel/Views/ViewsCacheabilityMetadataExportTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\search_api\Kernel\Views; use Drupal\Core\Cache\Context\CacheContextsManager; +use Drupal\Core\Cache\Context\ContextCacheKeys; use Drupal\Core\Config\Config; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\KernelTests\KernelTestBase; @@ -75,6 +76,7 @@ public function register(ContainerBuilder $container) { // error. $cache_contexts_manager = $this->createMock(CacheContextsManager::class); $cache_contexts_manager->method('assertValidTokens')->willReturn(TRUE); + $cache_contexts_manager->method('convertTokensToKeys')->willReturn(new ContextCacheKeys([])); $container->set('cache_contexts_manager', $cache_contexts_manager); }