diff --git a/config/schema/search_api.processor.schema.yml b/config/schema/search_api.processor.schema.yml index eb1d2da..5338e4b 100644 --- a/config/schema/search_api.processor.schema.yml +++ b/config/schema/search_api.processor.schema.yml @@ -32,12 +32,12 @@ plugin.plugin_configuration.search_api_processor.hierarchy: type: search_api.default_processor_configuration label: 'Hierarchy processor configuration' mapping: - hierarchy_fields: + fields: type: sequence - label: 'Fields to index hierarchy for' + label: 'Fields for which to add the hierarchy' sequence: type: string - label: 'Field' + label: 'Field ID' plugin.plugin_configuration.search_api_processor.highlight: type: search_api.default_processor_configuration diff --git a/src/Plugin/search_api/processor/AddHierarchy.php b/src/Plugin/search_api/processor/AddHierarchy.php index 220c5c8..24f392d 100644 --- a/src/Plugin/search_api/processor/AddHierarchy.php +++ b/src/Plugin/search_api/processor/AddHierarchy.php @@ -3,15 +3,20 @@ namespace Drupal\search_api\Plugin\search_api\processor; use Drupal\Component\Utility\Html; +use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\Plugin\DataType\EntityAdapter; +use Drupal\Core\Entity\TypedData\EntityDataDefinitionInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\PluginFormInterface; +use Drupal\Core\TypedData\ComplexDataDefinitionInterface; +use Drupal\search_api\IndexInterface; use Drupal\search_api\Item\FieldInterface; use Drupal\search_api\Item\ItemInterface; use Drupal\search_api\Plugin\PluginFormTrait; use Drupal\search_api\Processor\ProcessorPluginBase; +use Drupal\search_api\Utility\FieldsHelperInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -31,23 +36,51 @@ class AddHierarchy extends ProcessorPluginBase implements PluginFormInterface { /** * The entity type manager. * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface + * @var \Drupal\Core\Entity\EntityTypeManagerInterface|null */ protected $entityTypeManager; /** + * The fields helper. + * + * @var \Drupal\search_api\Utility\FieldsHelperInterface|null + */ + protected static $fieldsHelper; + + /** * {@inheritdoc} */ - public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + public static function create(ContainerInterface $container, array $configuration, $pluginId, $pluginDefinition) { /** @var static $processor */ - $processor = parent::create($container, $configuration, $plugin_id, $plugin_definition); + $processor = parent::create($container, $configuration, $pluginId, $pluginDefinition); $processor->setEntityTypeManager($container->get('entity_type.manager')); + static::setFieldsHelper($container->get('search_api.fields_helper')); return $processor; } /** + * Retrieves the fields helper. + * + * @return \Drupal\search_api\Utility\FieldsHelperInterface + * The fields helper. + */ + public static function getFieldsHelper() { + return static::$fieldsHelper ?: \Drupal::service('search_api.fields_helper'); + } + + /** + * Sets the fields helper. + * + * @param \Drupal\search_api\Utility\FieldsHelperInterface $fieldsHelper + * The new fields helper. + */ + public static function setFieldsHelper(FieldsHelperInterface $fieldsHelper) { + static::$fieldsHelper = $fieldsHelper; + } + + /** * Retrieves the entity type manager service. * * @return \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager @@ -60,43 +93,124 @@ public function getEntityTypeManager() { /** * Sets the entity type manager service. * - * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager * The entity type manager service. * * @return $this */ - public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) { - $this->entityTypeManager = $entity_type_manager; + public function setEntityTypeManager(EntityTypeManagerInterface $entityTypeManager) { + $this->entityTypeManager = $entityTypeManager; return $this; } /** * {@inheritdoc} */ + public static function supportsIndex(IndexInterface $index) { + return (bool) static::getHierarchyFields($index); + } + + /** + * Finds all (potentially) hierarchical fields for the given index. + * + * Fields are returned if: + * - they point to an entity type; and + * - that entity type contains a property referencing the same type of entity + * (so that a hierarchy could be built from that nested property). + * + * @param \Drupal\search_api\IndexInterface $index + * The index for which hierarchical fields should be found. + * + * @return string[][] + * An array containing all fields of the index for which hierarchical data + * might be retrievable. The keys are those field's IDs, the values are + * associative arrays containing the nested properties of those fields from + * which a hierarchy might be constructed, with the property paths as the + * keys and labels as the values. + */ + protected static function getHierarchyFields(IndexInterface $index) { + $fieldOptions = array(); + + foreach ($index->getFields() as $field_id => $field) { + $definition = $field->getDataDefinition(); + if ($definition instanceof ComplexDataDefinitionInterface) { + $properties = $definition->getPropertyDefinitions(); + // The property might be an entity data definition itself. + $properties[''] = $definition; + foreach ($properties as $property) { + $property_label = $property->getLabel(); + $property = static::$fieldsHelper->getInnerProperty($property); + if ($property instanceof EntityDataDefinitionInterface) { + $entity_type_id = $property->getEntityTypeId(); + foreach ($property->getPropertyDefinitions() as $name_2 => $property_2) { + $property_2_label = $property_2->getLabel(); + $property_2 = static::$fieldsHelper->getInnerProperty($property_2); + if ($property_2 instanceof EntityDataDefinitionInterface) { + if ($property_2->getEntityTypeId() == $entity_type_id) { + $fieldOptions[$field_id]["$entity_type_id-$name_2"] = Html::escape($property_label . ' » ' . $property_2_label); + } + } + elseif ($property_2 instanceof ComplexDataDefinitionInterface) { + foreach ($property_2->getPropertyDefinitions() as $property_3) { + $property_3 = static::$fieldsHelper->getInnerProperty($property_3); + if ($property_3 instanceof EntityDataDefinitionInterface) { + if ($property_3->getEntityTypeId() == $entity_type_id) { + $fieldOptions[$field_id]["$entity_type_id-$name_2"] = Html::escape($property_label . ' » ' . $property_2_label); + break; + } + } + } + } + } + } + } + } + } + + return $fieldOptions; + } + + /** + * {@inheritdoc} + */ public function defaultConfiguration() { return array( - 'hierarchy_fields' => array(), + 'fields' => array(), ); } /** * {@inheritdoc} */ - public function buildConfigurationForm(array $form, FormStateInterface $form_state) { - $field_options = array(); - foreach ($this->index->getFields() as $field_id => $field) { - if ($this->isEntityReferenceField($field)) { - $field_options[$field_id] = Html::escape($field->getPrefixedLabel()); - } - } + public function buildConfigurationForm(array $form, FormStateInterface $formState) { + $form['#description'] = $this->t('Select the fields to which hierarchical data should be added.'); - $form['hierarchy_fields'] = array( - '#type' => 'checkboxes', - '#title' => $this->t('Enable this processor on the following fields'), - '#description' => $this->t("These are the fields on the index which might contain hierarchical data. Enable those for which you want to include all values' ancestors in the indexed values."), - '#options' => $field_options, - '#default_value' => $this->configuration['hierarchy_fields'], - ); + foreach (static::getHierarchyFields($this->index) as $field_id => $options) { + $enabled = !empty($this->configuration['fields'][$field_id]); + $form['fields'][$field_id]['status'] = array( + '#type' => 'checkbox', + '#title' => $this->index->getField($field_id)->getLabel(), + '#default_value' => $enabled, + ); + reset($options); + $form['fields'][$field_id]['property'] = array( + '#type' => 'radios', + '#title' => $this->t('Hierarchy property to use'), + '#description' => $this->t("This field has several nested properties which look like they might contain hierarchy data for the field. Please pick the one that should be used."), + '#options' => $options, + '#default_value' => $enabled ? $this->configuration['fields'][$field_id] : key($options), + '#access' => count($options) > 1, + '#states' => array( + 'visible' => array( + // @todo This shouldn't be dependent on the form array structure. + // Use the '#process' trick instead. + ":input[name=\"processors[hierarchy][settings][fields][$field_id][status]\"]" => array( + 'checked' => TRUE, + ), + ), + ), + ); + } return $form; } @@ -104,9 +218,22 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta /** * {@inheritdoc} */ - public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { - $fields = array_filter($form_state->getValues()['hierarchy_fields']); - $form_state->setValue('hierarchy_fields', $fields); + public function validateConfigurationForm(array &$form, FormStateInterface $formState) { + $fields = array(); + foreach ($formState->getValue('fields', array()) as $field_id => $values) { + if (!empty($values['status'])) { + if (empty($values['property'])) { + $formState->setError($form['fields'][$field_id]['property'], $this->t('You need to select a nested property to use for the hierarchy data.')); + } + else { + $fields[$field_id] = $values['property']; + } + } + } + $formState->setValue('fields', $fields); + if (!$fields) { + $formState->setError($form['fields'], $this->t('You need to select at least one field for which to add hierarchy data.')); + } } /** @@ -115,92 +242,66 @@ public function validateConfigurationForm(array &$form, FormStateInterface $form public function preprocessIndexItems(array $items) { /** @var \Drupal\search_api\Item\ItemInterface $item */ foreach ($items as $item) { - foreach ($item->getFields() as $field) { - if (in_array($field->getFieldIdentifier(), $this->configuration['hierarchy_fields'])) { - // The field is an entity reference, so we have to traverse up the - // tree to find all parents. - $hierarchyValues = array(); - $this->extractHierarchy($item, $field, $hierarchyValues); - $field->setValues(array_unique($hierarchyValues)); + foreach ($this->configuration['fields'] as $field_id => $property_specifier) { + $field = $item->getField($field_id); + if (!$field) { + continue; + } + list ($entity_type_id, $property) = explode('-', $property_specifier); + foreach ($field->getValues() as $entity_id) { + $this->addHierarchyValues($entity_type_id, $entity_id, $property, $field); } } } } /** - * Determines if a given field is an entity reference field. + * Adds all ancestors' IDs of the given entity to the given field. * + * @param string $entityTypeId + * The entity type ID. + * @param mixed $entityId + * The ID of the entity for which ancestors should be found. + * @param string $property + * The name of the property on the entity type which contains the references + * to the parent entities. * @param \Drupal\search_api\Item\FieldInterface $field - * The field to check. - * - * @return bool - * Returns TRUE if the given field is an entity reference field. + * The field to which values should be added. */ - protected function isEntityReferenceField(FieldInterface $field) { - return $field->getDataDefinition()->getDataType() == 'field_item:entity_reference'; - } - - /** - * Extract the hierarchy of a given field. - * - * This method calls itself recursively and extracts the hierarchy by - * appending the `$values` array. - * - * Taxonomy references are handled differently since the hierarchy is not on - * the given item, but rather on the referenced vocabulary. - * - * @param \Drupal\search_api\Item\ItemInterface $item - * The item being indexed. - * @param \Drupal\search_api\Item\FieldInterface $field - * The specific entity reference field that defines the hierarchy. - * @param array $values - * The values array. - */ - protected function extractHierarchy(ItemInterface $item, FieldInterface $field, array &$values) { - // Load the entity storage for the given field. - $field_storage = $field->getDataDefinition()->getFieldDefinition()->getFieldStorageDefinition(); - $taxonomy_hierarchy = $field_storage->getSetting('target_type') == 'taxonomy_term'; - foreach ($field->getValues() as $id) { - // Initial value is always included. - $values[] = $id; - - // Special handling if this is a taxonomy field. - if ($taxonomy_hierarchy) { - // Add each parent. - foreach ($this->entityTypeManager->getStorage('taxonomy_term')->loadParents($id) as $parent) { - $values[] = $parent->id(); - } - - } - else { - $this->extractEntityReferenceHierarchy($id, $values, $field_storage); + protected function addHierarchyValues($entityTypeId, $entityId, $property, FieldInterface $field) { + if ("$entityTypeId-$property" == 'taxonomy_term-parent') { + /** @var \Drupal\taxonomy\TermStorageInterface $entityStorage */ + $entityStorage = $this->getEntityTypeManager() + ->getStorage('taxonomy_term'); + $parents = array(); + foreach ($entityStorage->loadParents($entityId) as $term) { + $parents[] = $term->id(); } } - - // For non-taxonomy hierarchies, add the item itself to complete the - // hierarchy. Top-level items (or bottom-level items depending on the - // direction) have no entity reference value. - if (!$taxonomy_hierarchy && $item->getOriginalObject() instanceof EntityAdapter) { - $values[] = $item->getOriginalObject()->getValue()->id(); + else { + $entity = $this->getEntityTypeManager() + ->getStorage($entityTypeId) + ->load($entityId); + $parents = array(); + if ($entity instanceof ContentEntityInterface) { + try { + foreach ($entity->get($property) as $data) { + $values = static::getFieldsHelper()->extractFieldValues($data); + $parents = array_merge($parents, $values); + } + } + catch (\InvalidArgumentException $e) { + // Might happen, for example, if the property only exists on a certain + // bundle, and this entity has the wrong one. + } + } } - } - /** - * Specific hierarchy extraction for non-taxonomy references. - * - * @param mixed $id - * The entity ID. - * @param array $values - * The values array to modify with hierarchy. - * @param \Drupal\Core\Field\FieldStorageDefinitionInterface $field_storage - * The entity storage. - */ - protected function extractEntityReferenceHierarchy($id, array &$values, FieldStorageDefinitionInterface $field_storage) { - $child = $this->entityTypeManager->getStorage($field_storage->getTargetEntityTypeId())->load($id); - foreach ($child->{$field_storage->getName()} as $value) { - $values[] = $value->target_id; - // @todo Check for circular references to avoid infinite recursion. - $this->extractEntityReferenceHierarchy($value->target_id, $values, $field_storage); + foreach ($parents as $parent) { + if (!in_array($parent, $field->getValues())) { + $field->addValue($parent); + $this->addHierarchyValues($entityTypeId, $parent, $property, $field); + } } } diff --git a/src/Tests/ProcessorIntegrationTest.php b/src/Tests/ProcessorIntegrationTest.php index a8b3818..dbd4b53 100644 --- a/src/Tests/ProcessorIntegrationTest.php +++ b/src/Tests/ProcessorIntegrationTest.php @@ -410,7 +410,12 @@ public function checkTransliterationIntegration() { * Tests the hierarchy processor. */ protected function checkAddHierarchyIntegration() { - $this->editSettingsForm(array('hierarchy_fields' => array('uid' => 'uid')), 'hierarchy'); + $configuration = array( + 'hierarchy_fields' => array( + 'uid' => 'uid', + ), + ); + $this->editSettingsForm($configuration, 'hierarchy'); } /** diff --git a/tests/src/Kernel/Processor/AddHierarchyTest.php b/tests/src/Kernel/Processor/AddHierarchyTest.php index df06a30..2f1a6d7 100644 --- a/tests/src/Kernel/Processor/AddHierarchyTest.php +++ b/tests/src/Kernel/Processor/AddHierarchyTest.php @@ -6,7 +6,7 @@ use Drupal\field\Tests\EntityReference\EntityReferenceTestTrait; use Drupal\node\Entity\NodeType; use Drupal\search_api\Item\Field; -use Drupal\search_api\Utility\Utility; +use Drupal\search_api\Query\Query; use Drupal\simpletest\NodeCreationTrait; use Drupal\taxonomy\Tests\TaxonomyTestTrait; use Drupal\Tests\search_api\Kernel\ResultsTrait; @@ -131,13 +131,13 @@ public function setUp($processor = NULL) { $this->index->save(); // Setup a node index. - $manager = $this->container->get('plugin.manager.search_api.datasource'); - $datasources['entity:node'] = $manager->createInstance('entity:node', ['#index' => $this->index]); + $datasources['entity:node'] = $this->index->createPlugin('datasource', 'entity:node'); $this->index->setDatasources($datasources); $this->index->save(); +# $this->index->removeDatasource('entity:comment')->save(); $this->container->get('search_api.index_task_manager')->addItemsAll($this->index); $index_storage = $this->container->get('entity_type.manager')->getStorage('search_api_index'); - $index_storage->resetCache([$this->index->id()]); + $index_storage->resetCache(array($this->index->id())); $this->index = $index_storage->load($this->index->id()); } @@ -181,30 +181,32 @@ public function testPreprocessIndexItemsTaxonomy() { // By default, hierarchy is not indexed, so a search for 'vegetable' should // only return node 2. - $query = Utility::createQuery($this->index); + $query = new Query($this->index); $query->addCondition('term_field', $this->terms['vegetable']->id()); $result = $query->execute(); $expected = array('node' => array(1)); $this->assertResults($result, $expected); // Enable hierarchical indexing. - $processor = $this->index->getProcessors()['hierarchy']; - $processor->setConfiguration(array('hierarchy_fields' => array('term_field'))); - $this->index->addProcessor($processor); + $processor = $this->index->getProcessor('hierarchy'); + $processor->setConfiguration(array( + 'fields' => array( + 'term_field' => 'taxonomy_term-parent', + ), + )); $this->index->save(); - $this->index->reindex(); $this->index->indexItems(); // Query for "vegetable" should return 2 items: // Node 1 is "vegetable.turnip" and node 2 is just "vegetable". - $query = Utility::createQuery($this->index); + $query = new Query($this->index); $query->addCondition('term_field', $this->terms['vegetable']->id()); $result = $query->execute(); $expected = array('node' => array(0, 1)); $this->assertResults($result, $expected); // A search for just turnips should return node 1 only. - $query = Utility::createQuery($this->index); + $query = new Query($this->index); $query->addCondition('term_field', $this->terms['vegetable.turnip']->id()); $result = $query->execute(); $expected = array('node' => array(0)); @@ -215,8 +217,7 @@ public function testPreprocessIndexItemsTaxonomy() { * Tests non-taxonomy-based hierarchy. * * @covers ::preprocessIndexItems - * @covers ::extractHierarchy - * @covers ::extractEntityReferenceHierarchy + * @covers ::addHierarchyValues */ public function testPreprocessIndexItems() { // Setup the nodes to follow the hierarchy. @@ -243,25 +244,27 @@ public function testPreprocessIndexItems() { // Initially hierarchy is excluded, so "vegetable" should only return nodes // 5 and 6. - $query = Utility::createQuery($this->index); + $query = new Query($this->index); $query->addCondition('parent_reference', $this->nodes[3]->id()); $result = $query->execute(); $expected = array('node' => array(4, 5)); $this->assertResults($result, $expected); // Enable hierarchical indexing. - $processor = $this->index->getProcessors()['hierarchy']; - $processor->setConfiguration(array('hierarchy_fields' => array('parent_reference'))); - $this->index->addProcessor($processor); + $processor = $this->index->getProcessor('hierarchy'); + $processor->setConfiguration(array( + 'fields' => array( + 'parent_reference' => 'node-parent_reference', + ), + )); $this->index->save(); - $this->index->reindex(); $this->index->indexItems(); // A search for "vegetable" should now include the hierarchy. - $query = Utility::createQuery($this->index); + $query = new Query($this->index); $query->addCondition('parent_reference', $this->nodes[3]->id()); $result = $query->execute(); - $expected = array('node' => array(3, 4, 5, 6, 7, 8)); + $expected = array('node' => array(4, 5, 6, 7, 8)); $this->assertResults($result, $expected); }