diff --git a/search_api.module b/search_api.module index ccbd9990..e90a24e8 100644 --- a/search_api.module +++ b/search_api.module @@ -24,6 +24,7 @@ use Drupal\search_api\Plugin\views\filter\SearchApiTerm as TermFilter; 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; @@ -251,6 +252,15 @@ function search_api_entity_update(EntityInterface $entity) { if (!($entity instanceof ContentEntityInterface) || $entity->search_api_skip_tracking) { return; } + + // Make sure to mark for reindexing any search item that stands affected by + // update to this entity. + foreach (Index::loadMultiple() as $index) { + if (($tracker = $index->getTrackerInstance()) && $tracker instanceof TrackerPluginBase) { + $tracker->entityChanged($entity); + } + } + $indexes = ContentEntity::getIndexesForEntity($entity); if (!$indexes) { return; @@ -320,6 +330,15 @@ function search_api_entity_delete(EntityInterface $entity) { if (!($entity instanceof ContentEntityInterface) || $entity->search_api_skip_tracking) { return; } + + // Make sure to mark for reindexing any search item that stands affected by + // deletion of this entity. + foreach (Index::loadMultiple() as $index) { + if (($tracker = $index->getTrackerInstance()) && $tracker instanceof TrackerPluginBase) { + $tracker->entityDeleted($entity); + } + } + $indexes = ContentEntity::getIndexesForEntity($entity); if (!$indexes) { return; diff --git a/src/Tracker/TrackerPluginBase.php b/src/Tracker/TrackerPluginBase.php index aeb6779b..3a54b0e9 100644 --- a/src/Tracker/TrackerPluginBase.php +++ b/src/Tracker/TrackerPluginBase.php @@ -2,7 +2,15 @@ namespace Drupal\search_api\Tracker; +use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\TypedData\EntityDataDefinition; +use Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\fatv_contextual_override\Plugin\search_api\tracker\BasicWithEntityOverride; use Drupal\search_api\Plugin\IndexPluginBase; +use Drupal\search_api\Utility\FieldsHelperInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Defines a base class from which other tracker classes may extend. @@ -33,7 +41,182 @@ use Drupal\search_api\Plugin\IndexPluginBase; */ abstract class TrackerPluginBase extends IndexPluginBase implements TrackerInterface { + /** + * Language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface + */ + protected $languageManager; + + /** + * Entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * Fields helper. + * + * @var \Drupal\search_api\Utility\FieldsHelperInterface + */ + protected $fieldsHelper; + // @todo Move some of the methods from // \Drupal\search_api\Plugin\search_api\tracker\Basic to here? + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + /** @var static $tracker */ + $tracker = parent::create($container, $configuration, $plugin_id, $plugin_definition); + + $tracker->setLanguageManager($container->get('language_manager')); + $tracker->setEntityTypeManager($container->get('entity_type.manager')); + $tracker->setFieldsHelper($container->get('search_api.fields_helper')); + + return $tracker; + } + + /** + * React to an arbitrary entity being updated. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * Arbitrary entity that just got updated. + */ + public function entityChanged(ContentEntityInterface $entity) { + $this->trackReferencedEntityCrud($entity, $entity->original); + } + + /** + * React to an arbitrary entity being deleted. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * Arbitrary entity that just got deleted. + */ + public function entityDeleted(ContentEntityInterface $entity) { + $this->trackReferencedEntityCrud($entity); + } + + /** + * Setter for language manager. + * + * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager + * Language manager to inject into the object. + */ + public function setLanguageManager(LanguageManagerInterface $language_manager) { + $this->languageManager = $language_manager; + } + + /** + * Setter for entity type manager. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * Entity type manager to inject into the object. + */ + public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) { + $this->entityTypeManager = $entity_type_manager; + } + + /** + * Setter for fields helper. + * + * @param \Drupal\search_api\Utility\FieldsHelperInterface $fields_helper + * Fields helper to inject into the object. + */ + public function setFieldsHelper(FieldsHelperInterface $fields_helper) { + $this->fieldsHelper = $fields_helper; + } + + /** + * Track references in the Search index set up and react to entity changes. + * + * This method detects whether update to an arbitrary entity implies some + * search items from this index have to be re-indexed anew. It is the case + * when the search items utilize values from other entities (possibly through + * an "entity reference" field) and thus updating an arbitrary foreign entity + * might bring a search index to a stale state. So here we smartly analyze the + * search index fields and compare it against the entity that just got either + * updated or removed. If we detect the changed occurred to an arbitrary + * entity brings some of the search items into an inconsistent state, we mark + * them for re-indexing so Search API catches up on this different and + * propagates the new correct data into the search server. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * Arbitrary entity that just got changed (updated or deleted). + * @param \Drupal\Core\Entity\ContentEntityInterface|NULL $original_entity + * If it was an update, supply here the original entity that corresponds to + * the $entity input argument. Empty value here would imply $entity just got + * deleted. + */ + protected function trackReferencedEntityCrud(ContentEntityInterface $entity, ContentEntityInterface $original_entity = NULL) { + $glue = ':'; + + foreach ($this->getIndex()->getFields() as $field) { + if ($datasource = $field->getDatasource()) { + $property_definitions = $datasource->getPropertyDefinitions(); + + $field_property = explode($glue, $field->getPropertyPath()); + + $property_path_to_entity = FALSE; + for ($i = 1; $i <= count($field_property); $i++) { + $property_definition = $this->fieldsHelper->retrieveNestedProperty($property_definitions, implode($glue, array_slice($field_property, 0, $i))); + + if ($property_path_to_entity && $property_definition instanceof FieldItemDataDefinitionInterface) { + // Parent is an entity. Hence this level is fields of the entity. + $field_name = $property_definition->getFieldDefinition()->getName(); + + $items = $entity->get($field_name); + + // We trigger re-indexing if either it is a removed entity or the + // entity has changed its field value (if it's an update). + if (!$original_entity || !$items->equals($original_entity->get($field_name))) { + $query = $this->entityTypeManager->getStorage($datasource->getEntityTypeId())->getQuery(); + $query->accessCheck(FALSE); + $query->condition($property_path_to_entity, $entity->id()); + + $ids_to_reindex = []; + $languages = array_keys($this->languageManager->getLanguages()); + + foreach (array_values($query->execute()) as $id_to_reindex) { + foreach ($languages as $language) { + $ids_to_reindex[] = "$id_to_reindex:$language"; + } + } + + if (!empty($ids_to_reindex)) { + $this->getIndex()->trackItemsUpdated($datasource->getPluginId(), $ids_to_reindex); + } + + // We've already discovered this entity is affecting our index. It + // is pointless to continue examining the rest of the search index + // fields as we've already confirm we have to mark the search + // items that point to this entity for re-indexing. + return; + } + + // Currently we only track 1 level of nesting. So we break the loop + // over the chunks of property path now as we have already processed + // the 1st level of entity referencing. + break; + } + + if ($property_definition instanceof EntityDataDefinition && $entity->getEntityTypeId() == $property_definition->getEntityTypeId()) { + $property_path_to_entity = implode($glue, array_slice($field_property, 0, $i - 1)); + + $entity_property = $this->fieldsHelper->retrieveNestedProperty($property_definitions, $property_path_to_entity); + + if (!$entity_property instanceof FieldItemDataDefinitionInterface || $entity_property->getFieldDefinition()->isComputed()) { + // This is not a field or a computed one. We have no means to look + // up search items that point to this entity. In the absence of a + // better alternative, let's just keep searching. + $property_path_to_entity = FALSE; + } + } + } + } + } + } + } diff --git a/tests/search_api_test_example_content_references/config/install/field.field.node.child.indexed.yml b/tests/search_api_test_example_content_references/config/install/field.field.node.child.indexed.yml new file mode 100644 index 00000000..a1b7be19 --- /dev/null +++ b/tests/search_api_test_example_content_references/config/install/field.field.node.child.indexed.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.indexed + - node.type.child + module: + - node +id: node.child.indexed +field_name: indexed +entity_type: node +bundle: child +label: 'Indexed' +description: 'This field is supposed to be indexed by Search API.' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/tests/search_api_test_example_content_references/config/install/field.field.node.child.not_indexed.yml b/tests/search_api_test_example_content_references/config/install/field.field.node.child.not_indexed.yml new file mode 100644 index 00000000..010c0d0e --- /dev/null +++ b/tests/search_api_test_example_content_references/config/install/field.field.node.child.not_indexed.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.not_indexed + - node.type.child + module: + - node +id: node.child.not_indexed +field_name: not_indexed +entity_type: node +bundle: child +label: 'Not Indexed' +description: 'This field is not supposed to be indexed by Search API.' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/tests/search_api_test_example_content_references/config/install/field.field.node.parent.entity_reference.yml b/tests/search_api_test_example_content_references/config/install/field.field.node.parent.entity_reference.yml new file mode 100644 index 00000000..2937cc0b --- /dev/null +++ b/tests/search_api_test_example_content_references/config/install/field.field.node.parent.entity_reference.yml @@ -0,0 +1,25 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.entity_reference + - node.type.parent + - node.type.child +id: node.parent.entity_reference +field_name: entity_reference +entity_type: node +bundle: parent +label: Reference +description: 'Reference to the child node type.' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:node' + handler_settings: + target_bundles: + child: child + auto_create: false + auto_create_bundle: '' +field_type: entity_reference diff --git a/tests/search_api_test_example_content_references/config/install/field.storage.node.entity_reference.yml b/tests/search_api_test_example_content_references/config/install/field.storage.node.entity_reference.yml new file mode 100644 index 00000000..588fb38b --- /dev/null +++ b/tests/search_api_test_example_content_references/config/install/field.storage.node.entity_reference.yml @@ -0,0 +1,18 @@ +langcode: en +status: true +dependencies: + module: + - node +id: node.entity_reference +field_name: entity_reference +entity_type: node +type: entity_reference +settings: + target_type: node +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/tests/search_api_test_example_content_references/config/install/field.storage.node.indexed.yml b/tests/search_api_test_example_content_references/config/install/field.storage.node.indexed.yml new file mode 100644 index 00000000..c0de2fef --- /dev/null +++ b/tests/search_api_test_example_content_references/config/install/field.storage.node.indexed.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + module: + - node +id: node.indexed +field_name: indexed +entity_type: node +type: string +settings: + max_length: 255 + is_ascii: false + case_sensitive: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/tests/search_api_test_example_content_references/config/install/field.storage.node.not_indexed.yml b/tests/search_api_test_example_content_references/config/install/field.storage.node.not_indexed.yml new file mode 100644 index 00000000..af874d9f --- /dev/null +++ b/tests/search_api_test_example_content_references/config/install/field.storage.node.not_indexed.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + module: + - node +id: node.not_indexed +field_name: not_indexed +entity_type: node +type: string +settings: + max_length: 255 + is_ascii: false + case_sensitive: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/tests/search_api_test_example_content_references/config/install/node.type.child.yml b/tests/search_api_test_example_content_references/config/install/node.type.child.yml new file mode 100644 index 00000000..5b921f73 --- /dev/null +++ b/tests/search_api_test_example_content_references/config/install/node.type.child.yml @@ -0,0 +1,10 @@ +langcode: en +status: true +dependencies: { } +name: Child +type: child +description: 'Child node type that gets referenced by other types.' +help: '' +new_revision: true +preview_mode: 1 +display_submitted: true diff --git a/tests/search_api_test_example_content_references/config/install/node.type.parent.yml b/tests/search_api_test_example_content_references/config/install/node.type.parent.yml new file mode 100644 index 00000000..4e6430f2 --- /dev/null +++ b/tests/search_api_test_example_content_references/config/install/node.type.parent.yml @@ -0,0 +1,10 @@ +langcode: en +status: true +dependencies: { } +name: Parent +type: parent +description: 'Parent node type that references other types.' +help: '' +new_revision: true +preview_mode: 1 +display_submitted: true diff --git a/tests/search_api_test_example_content_references/search_api_test_example_content_references.info.yml b/tests/search_api_test_example_content_references/search_api_test_example_content_references.info.yml new file mode 100644 index 00000000..31e3132f --- /dev/null +++ b/tests/search_api_test_example_content_references/search_api_test_example_content_references.info.yml @@ -0,0 +1,8 @@ +type: module +name: 'Example Content with references' +description: 'Provides field definitions for example content that include entity references.' +package: Testing +dependencies: + - drupal:node +core: 8.x +hidden: true diff --git a/tests/src/Kernel/BasicTrackerReferencedEntity.php b/tests/src/Kernel/BasicTrackerReferencedEntity.php new file mode 100644 index 00000000..efbaf059 --- /dev/null +++ b/tests/src/Kernel/BasicTrackerReferencedEntity.php @@ -0,0 +1,327 @@ +installSchema('search_api', ['search_api_item']); + $this->installSchema('node', ['node_access']); + $this->installEntitySchema('user'); + $this->installEntitySchema('node'); + $this->installEntitySchema('entity_test_mulrev_changed'); + $this->installEntitySchema('search_api_task'); + $this->installConfig(['search_api', 'search_api_test_db', '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); + } + + $this->index = Index::create([ + 'id' => 'index', + 'tracker_settings' => [ + 'default' => [], + ], + 'datasource_settings' => [ + 'entity:node' => [ + 'bundles' => [ + 'default' => FALSE, + 'selected' => ['parent'], + ], + ], + ], + 'server' => 'database_search_server', + 'field_settings' => [ + 'child_indexed' => [ + 'label' => 'Child > Indexed', + 'datasource_id' => 'entity:node', + 'property_path' => 'entity_reference:entity:indexed', + 'type' => 'text', + ], + ], + ]); + + $this->index->save(); + } + + /** + * Test basic tracker with respect to 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 child entity. Value can be either FALSE (to remove an + * entity) or a list of the new raw values to apply to the entity. + * @param string $message + * Some user-friendly assert message to utilize for this test case. + * + * @dataProvider referencedEntityChangedDataProvider + */ + public function testReferencedEntityChanged($parent_map, $child_map, $updates, $expected, $message) { + $children = $this->createEntitiesFromMap($child_map, [], 'child'); + $parents = $this->createEntitiesFromMap($parent_map, $children, 'parent'); + + $this->index->indexItems(); + + // Now let's execute updates. + foreach ($updates as $k => $field_updates) { + if ($field_updates === FALSE) { + $children[$k]->delete(); + } + else { + foreach ($field_updates as $field => $items) { + $children[$k]->get($field)->setValue($items); + } + + $children[$k]->save(); + } + } + + $pending = $this->index->getTrackerInstance()->getRemainingItems(); + + $this->assertSame($expected, $pending, "Pending search API items are correct. $message"); + } + + /** + * Provides test data for testReferencedEntityChanged(). + * + * @return array[] + * An array of argument arrays for testReferencedEntityChanged(). + * + * @see \Drupal\Tests\search_api\Kernel\BasicTrackerReferencedEntity::testReferencedEntityChanged() + */ + public function referencedEntityChangedDataProvider() { + $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[] = [ + $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, + "Changing value of a not indexed field within the $child entity.", + ]; + + $tests[] = [ + $parent_map, + [ + 'child' => [ + 'title' => 'Child', + 'indexed' => 'Original indexed value', + ], + 'unrelated' => [ + 'title' => 'Unrelated child', + 'indexed' => 'Original indexed value', + ], + ], + [ + $child => [ + $field => "New $field value.", + ], + ], + $expected, + "Appending value of a not indexed field within the $child entity.", + ]; + + $tests[] = [ + $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, + "Removing value of a not indexed field within the $child entity.", + ]; + } + } + + $tests[] = [ + $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.', + ], + ], + [ + 'unrelated' => FALSE, + ], + [], + 'Removing unrelated entity.', + ]; + + $tests[] = [ + $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, + ], + $parent_expected, + 'Removing child entity.', + ]; + + return $tests; + } + + /** + * Create a list of entities per the supplied map. + * + * @param array $map + * Map of entities to create. It should be keyed by machine-friendly name. + * Values of this map should be subarrays that represent raw values to + * supply into the entity's fields when creating it. + * @param \Drupal\Core\Entity\ContentEntityInterface[] $references_map + * There is magical field "entity_reference" in the $map input argument. + * Values of this field should indicate to 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 (and this is the reason to use some machine names for entities). + * @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($map, $references_map, $bundle) { + $entities = []; + + $entity_type = \Drupal::entityTypeManager()->getDefinition($this->entityType); + + foreach ($map as $k => $entity) { + if (isset($entity['entity_reference'])) { + $entity['entity_reference'] = $references_map[$entity['entity_reference']]->id(); + } + + $entity[$entity_type->getKey('bundle')] = $bundle; + + $entity = \Drupal::entityTypeManager()->getStorage($entity_type->id())->create($entity); + $entity->save(); + + $entities[$k] = $entity; + } + + return $entities; + } + +}