diff --git a/config/schema/search_api.views.schema.yml b/config/schema/search_api.views.schema.yml index 93a2cdf..f2472ac 100644 --- a/config/schema/search_api.views.schema.yml +++ b/config/schema/search_api.views.schema.yml @@ -5,9 +5,6 @@ views.query.search_api_query: search_api_bypass_access: type: boolean label: If the underlying search index has access checks enabled, this option allows you to disable them for this view. - entity_access: - type: boolean - label: Execute an access check for all result entities. parse_mode: type: string label: Chooses how the search keys will be parsed. diff --git a/search_api.api.php b/search_api.api.php index 55eaee4..0029319 100644 --- a/search_api.api.php +++ b/search_api.api.php @@ -101,11 +101,12 @@ function hook_search_api_field_type_mapping_alter(array &$mapping) { * Alter the mapping of Search API data types to their default Views handlers. * * @param array $mapping - * An associative array with data types as the keys and Views field data - * definitions as the values. In addition to all normally defined data types, - * keys can also be "options" for any field with an options list, "entity" for - * general entity-typed fields or "entity:ENTITY_TYPE" (with "ENTITY_TYPE" - * being the machine name of an entity type) for entities of that type. + * An associative array with data types as the keys and Views table data + * definition items as the values. In addition to all normally defined Search + * API data types, keys can also be "options" for any field with an options + * list, "entity" for general entity-typed fields or "entity:ENTITY_TYPE" + * (with "ENTITY_TYPE" being the machine name of an entity type) for entities + * of that type. */ function hook_search_api_views_handler_mapping_alter(array &$mapping) { $mapping['entity:my_entity_type'] = array( @@ -126,6 +127,31 @@ function hook_search_api_views_handler_mapping_alter(array &$mapping) { } /** + * Alter the mapping of property types to their default Views field handlers. + * + * This is used in the Search API Views integration to create Search + * API-specific field handlers for all properties of datasources and some entity + * types. + * + * @param array $mapping + * An associative array with property data types as the keys and Views field + * handler definitions as the values (i.e., just the inner "field" portion of + * Views data definition items). In some cases the value might also be NULL + * instead, to indicate that properties of this type shouldn't have field + * handlers. Any data types not listed will map to the "search_api" field + * handler. + */ +function hook_search_api_views_field_handler_mapping_alter(array &$mapping) { + $mapping['field_item:string_long'] = array( + 'id' => 'example_field', + ); + $mapping['example_property_type'] = array( + 'id' => 'example_field', + 'some_option' => 'foo', + ); +} + +/** * Allows you to log or alter the items that are indexed. * * Please be aware that generally preventing the indexing of certain items is diff --git a/search_api.views.inc b/search_api.views.inc index fa4a810..3658060 100644 --- a/search_api.views.inc +++ b/search_api.views.inc @@ -5,7 +5,10 @@ * Views hook implementations for the Search API module. */ -use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Field\TypedData\FieldItemDataDefinition; +use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\search_api\Datasource\DatasourceInterface; use Drupal\search_api\Entity\Index; use Drupal\search_api\Item\FieldInterface; use Drupal\search_api\SearchApiException; @@ -13,31 +16,57 @@ use Drupal\search_api\Utility; /** * Implements hook_views_data(). + * + * For each search index, we provide the following tables: + * - One base table, with key "search_api_index_INDEX", which contains field, + * filter, argument and sort handlers for all indexed fields. (Field handlers, + * too, to allow things like click-sorting.) + * - Tables for each datasource, by default with key + * "search_api_datasource_INDEX_DATASOURCE", with field and (where applicable) + * relationship handlers for each property of the datasource. Those will be + * joined to the index base table by default. + * + * Also, for each entity type that appears as the target entity type of an + * entity reference property in any datasource (or in any entity type thus + * reached), a table with field/relationship handlers for all of the entity + * type's properties. Those tables will use the key "search_api_entity_ENTITY". */ function search_api_views_data() { $data = array(); - try { - /** @var \Drupal\search_api\IndexInterface $index */ - foreach (Index::loadMultiple() as $index) { + + /** @var \Drupal\search_api\IndexInterface $index */ + foreach (Index::loadMultiple() as $index) { + try { // Fill in base data. $key = 'search_api_index_' . $index->id(); $table = &$data[$key]; - $table['table']['group'] = t('Index @name', array('@name' => $index->label())); + $index_label = $index->label(); + $table['table']['group'] = t('Index @name', array('@name' => $index_label)); $table['table']['base'] = array( 'field' => 'search_api_id', 'index' => $index->id(), - 'title' => t('Index @name', array('@name' => $index->label())), - 'help' => t('Use the %name search index for filtering and retrieving data.', array('%name' => $index->label())), + 'title' => t('Index @name', array('@name' => $index_label)), + 'help' => t('Use the @name search index for filtering and retrieving data.', array('@name' => $index_label)), 'query_id' => 'search_api_query', ); - /** @var \Drupal\search_api\Item\FieldInterface $field */ + // Add suitable handlers for all indexed fields. foreach ($index->getFields() as $field_id => $field) { $field_alias = _search_api_views_find_field_alias($field_id, $table); $field_definition = _search_api_views_get_handlers($field); + // The field handler has to be extra, since it is a) determined by the + // field's underlying property and b) needs a different "real field" + // set. + $field_handler = _search_api_views_get_field_handler_for_property($field->getDataDefinition()); + if ($field_handler) { + $field_definition['field'] = $field_handler; + $field_definition['field']['real field'] = $field->getCombinedPropertyPath(); + $field_definition['field']['click sortable'] = TRUE; + } if ($field_definition) { + $field_label = $field->getLabel(); $field_definition += array( - 'title' => $field->getLabel(), + 'title' => $field_label, 'help' => $field->getDescription(), ); if ($datasource = $field->getDatasource()) { @@ -49,58 +78,36 @@ function search_api_views_data() { if ($field_id != $field_alias) { $field_definition['real field'] = $field_id; } + if (isset($field_definition['field'])) { + $field_definition['field']['title'] = t('@field (indexed)', array('@field' => $field_label)); + } $table[$field_alias] = $field_definition; } } - if (isset($table['search_api_language']['filter']['id'])) { - $table['search_api_language']['filter']['id'] = 'search_api_language'; - $table['search_api_language']['filter']['allow empty'] = FALSE; - } + // Add special fields. + _search_api_views_data_special_fields($table); - // Add handlers for special fields. - $table['search_api_id']['title'] = t('Entity ID'); - $table['search_api_id']['help'] = t("The entity's ID"); - $table['search_api_id']['field']['id'] = 'numeric'; - $table['search_api_id']['sort']['id'] = 'search_api'; - - $table['search_api_datasource']['title'] = t('Datasource'); - $table['search_api_datasource']['help'] = t("The data source ID"); - $table['search_api_datasource']['field']['id'] = 'standard'; - $table['search_api_datasource']['filter']['id'] = 'search_api_datasource'; - $table['search_api_datasource']['sort']['id'] = 'search_api'; - - $table['search_api_relevance']['group'] = t('Search'); - $table['search_api_relevance']['title'] = t('Relevance'); - $table['search_api_relevance']['help'] = t('The relevance of this search result with respect to the query'); - $table['search_api_relevance']['field']['type'] = 'decimal'; - $table['search_api_relevance']['field']['id'] = 'numeric'; - $table['search_api_relevance']['field']['click sortable'] = TRUE; - $table['search_api_relevance']['sort']['id'] = 'search_api'; - - $table['search_api_excerpt']['group'] = t('Search'); - $table['search_api_excerpt']['title'] = t('Excerpt'); - $table['search_api_excerpt']['help'] = t('The search result excerpted to show found search terms'); - $table['search_api_excerpt']['field']['id'] = 'search_api_excerpt'; - - $table['search_api_fulltext']['group'] = t('Search'); - $table['search_api_fulltext']['title'] = t('Fulltext search'); - $table['search_api_fulltext']['help'] = t('Search several or all fulltext fields at once.'); - $table['search_api_fulltext']['filter']['id'] = 'search_api_fulltext'; - $table['search_api_fulltext']['argument']['id'] = 'search_api_fulltext'; - - $table['search_api_more_like_this']['group'] = t('Search'); - $table['search_api_more_like_this']['title'] = t('More like this'); - $table['search_api_more_like_this']['help'] = t('Find similar content.'); - $table['search_api_more_like_this']['argument']['id'] = 'search_api_more_like_this'; - - // @todo Add an "All taxonomy terms" contextual filter (if applicable). + // Add relationships for field data of all datasources. + $datasource_tables_prefix = 'search_api_datasource_' . $index->id() . '_'; + foreach ($index->getDatasources() as $datasource_id => $datasource) { + $table_key = _search_api_views_find_field_alias($datasource_tables_prefix . $datasource_id, $data); + $data[$table_key] = _search_api_views_datasource_table($datasource, $data); + // Automatically join this table for views of this index. + $data[$table_key]['table']['join'][$key] = array( + 'join_id' => 'search_api', + ); + } + } + catch (\Exception $e) { + $args = array( + '%index' => $index->label(), + ); + watchdog_exception('search_api', $e, '%type while computing Views data for index %index: @message in %function (line %line of %file).', $args); } } - catch (Exception $e) { - watchdog_exception('search_api', $e); - } - return $data; + + return array_filter($data); } /** @@ -163,16 +170,13 @@ function _search_api_views_get_handlers(FieldInterface $field) { try { $types = array(); - $definition = $field->getDataDefinition(); - $definition = Utility::getInnerProperty($definition); - - if ($definition instanceof FieldDefinitionInterface) { - if ($definition->getType() == 'entity_reference') { - $types[] = 'entity:' . $definition->getSetting('target_type'); - $types[] = 'entity'; - } + $definition = $field->getDataDefinition(); + if ($definition->getSetting('target_type')) { + $types[] = 'entity:' . $definition->getSetting('target_type'); + $types[] = 'entity'; } + // @todo Detect fields with fixed lists of options, add type "options". $types[] = $field->getType(); /** @var \Drupal\search_api\DataType\DataTypeInterface $data_type */ @@ -191,40 +195,13 @@ function _search_api_views_get_handlers(FieldInterface $field) { catch (SearchApiException $e) { $vars['%index'] = $field->getIndex()->label(); $vars['%field'] = $field->getPrefixedLabel(); - watchdog_exception('search_api', $e, '%type while Views handlers for field %field on index %index: @message in %function (line %line of %file).', $vars); + watchdog_exception('search_api', $e, '%type while adding Views handlers for field %field on index %index: @message in %function (line %line of %file).', $vars); } return array(); } /** - * Makes necessary, field-specific adjustments to Views handler definitions. - * - * @param string $type - * The type of field, as defined in _search_api_views_handler_mapping(). - * @param \Drupal\search_api\Item\FieldInterface $field - * The field whose handler definitions are being created. - * @param array $definitions - * The handler definitions for the field, as a reference. - */ -function _search_api_views_handler_adjustments($type, FieldInterface $field, array &$definitions) { - $data_definition = $field->getDataDefinition(); - if ($type == 'entity:taxonomy_term') { - if (isset($data_definition->getSettings()['handler_settings']['target_bundles'])) { - $target_bundles = $data_definition->getSettings()['handler_settings']['target_bundles']; - if (count($target_bundles) == 1) { - $definitions['filter']['vocabulary'] = reset($target_bundles); - } - } - } - // By default, all fields can be empty (or at least have to be treated that - // way by the Search API). - if (!isset($definitions['filter']['allow empty'])) { - $definitions['filter']['allow empty'] = TRUE; - } -} - -/** * Determines the mapping of Search API data types to their Views handlers. * * @return array @@ -321,3 +298,336 @@ function _search_api_views_handler_mapping() { return $mapping; } + +/** + * Makes necessary, field-specific adjustments to Views handler definitions. + * + * @param string $type + * The type of field, as defined in _search_api_views_handler_mapping(). + * @param \Drupal\search_api\Item\FieldInterface $field + * The field whose handler definitions are being created. + * @param array $definitions + * The handler definitions for the field, as a reference. + */ +function _search_api_views_handler_adjustments($type, FieldInterface $field, array &$definitions) { + // By default, all fields can be empty (or at least have to be treated that + // way by the Search API). + if (!isset($definitions['filter']['allow empty'])) { + $definitions['filter']['allow empty'] = TRUE; + } + + // For taxonomy term references, set the referenced vocabulary. + $data_definition = $field->getDataDefinition(); + if ($type == 'entity:taxonomy_term') { + if (isset($data_definition->getSettings()['handler_settings']['target_bundles'])) { + $target_bundles = $data_definition->getSettings()['handler_settings']['target_bundles']; + if (count($target_bundles) == 1) { + $definitions['filter']['vocabulary'] = reset($target_bundles); + } + } + } + + // Special case for our own language field. + if ($field->getCombinedPropertyPath() === 'search_api_language') { + $definitions['filter']['id'] = 'search_api_language'; + $definitions['filter']['allow empty'] = FALSE; + } +} + +/** + * Adds definitions for our special fields to a Views data table definition. + * + * @param array $table + * The existing Views data table definition. + */ +function _search_api_views_data_special_fields(array &$table) { + $id_field = _search_api_views_find_field_alias('search_api_id', $table); + $table[$id_field]['title'] = t('Entity ID'); + $table[$id_field]['help'] = t("The entity's ID"); + $table[$id_field]['field']['id'] = 'numeric'; + $table[$id_field]['sort']['id'] = 'search_api'; + if ($id_field != 'search_api_id') { + $table[$id_field]['real field'] = 'search_api_id'; + } + + $datasource_field = _search_api_views_find_field_alias('search_api_datasource', $table); + $table[$datasource_field]['title'] = t('Datasource'); + $table[$datasource_field]['help'] = t("The data source ID"); + $table[$datasource_field]['field']['id'] = 'standard'; + $table[$datasource_field]['filter']['id'] = 'search_api_datasource'; + $table[$datasource_field]['sort']['id'] = 'search_api'; + if ($datasource_field != 'search_api_datasource') { + $table[$datasource_field]['real field'] = 'search_api_datasource'; + } + + $relevance_field = _search_api_views_find_field_alias('search_api_relevance', $table); + $table[$relevance_field]['group'] = t('Search'); + $table[$relevance_field]['title'] = t('Relevance'); + $table[$relevance_field]['help'] = t('The relevance of this search result with respect to the query'); + $table[$relevance_field]['field']['type'] = 'decimal'; + $table[$relevance_field]['field']['id'] = 'numeric'; + $table[$relevance_field]['field']['click sortable'] = TRUE; + $table[$relevance_field]['sort']['id'] = 'search_api'; + if ($relevance_field != 'search_api_relevance') { + $table[$relevance_field]['real field'] = 'search_api_relevance'; + } + + $excerpt_field = _search_api_views_find_field_alias('search_api_excerpt', $table); + $table[$excerpt_field]['group'] = t('Search'); + $table[$excerpt_field]['title'] = t('Excerpt'); + $table[$excerpt_field]['help'] = t('The search result excerpted to show found search terms'); + $table[$excerpt_field]['field']['id'] = 'search_api'; + $table[$excerpt_field]['field']['html'] = TRUE; + if ($excerpt_field != 'search_api_excerpt') { + $table[$excerpt_field]['real field'] = 'search_api_excerpt'; + } + + $fulltext_field = _search_api_views_find_field_alias('search_api_fulltext', $table); + $table[$fulltext_field]['group'] = t('Search'); + $table[$fulltext_field]['title'] = t('Fulltext search'); + $table[$fulltext_field]['help'] = t('Search several or all fulltext fields at once.'); + $table[$fulltext_field]['filter']['id'] = 'search_api_fulltext'; + $table[$fulltext_field]['argument']['id'] = 'search_api_fulltext'; + if ($fulltext_field != 'search_api_fulltext') { + $table[$fulltext_field]['real field'] = 'search_api_fulltext'; + } + + $mlt_field = _search_api_views_find_field_alias('search_api_more_like_this', $table); + $table[$mlt_field]['group'] = t('Search'); + $table[$mlt_field]['title'] = t('More like this'); + $table[$mlt_field]['help'] = t('Find similar content.'); + $table[$mlt_field]['argument']['id'] = 'search_api_more_like_this'; + if ($mlt_field != 'search_api_more_like_this') { + $table[$mlt_field]['real field'] = 'search_api_more_like_this'; + } + + // @todo Add an "All taxonomy terms" contextual filter (if applicable). +} + +/** + * Creates a Views table definition for one datasource of an index. + * + * @param \Drupal\search_api\Datasource\DatasourceInterface $datasource + * The datasource for which to create a table definition. + * @param array $data + * The existing Views data definitions. Passed by reference so additionally + * needed tables can be inserted. + * + * @return array + * A Views table definition for the given datasource. + */ +function _search_api_views_datasource_table(DatasourceInterface $datasource, array &$data) { + $datasource_id = $datasource->getPluginId(); + $table = array( + 'table' => array( + 'group' => $datasource->label(), + 'index' => $datasource->getIndex()->id(), + 'datasource' => $datasource_id, + ), + ); + $entity_type_id = $datasource->getEntityTypeId(); + if ($entity_type_id) { + $table['table']['entity type'] = $entity_type_id; + } + + _search_api_views_add_handlers_for_properties($datasource->getPropertyDefinitions(), $table, $data); + + // Prefix the "real field" of each entry with the datasource ID. + foreach ($table as $key => $definition) { + if ($key == 'table') { + continue; + } + + $real_field = isset($definition['real field']) ? $definition['real field'] : $key; + $table[$key]['real field'] = Utility::createCombinedId($datasource_id, $real_field); + } + + return $table; +} + +/** + * Creates a Views table definition for an entity type. + * + * @param string $entity_type_id + * The ID of the entity type. + * @param array $data + * The existing Views data definitions, passed by reference. + * + * @return array + * A Views table definition for the given entity type. Or an empty array if + * the entity type could not be found. + */ +function _search_api_views_entity_type_table($entity_type_id, array &$data) { + $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id); + if (!$entity_type || !$entity_type->isSubclassOf(FieldableEntityInterface::class)) { + return array(); + } + + $table = array( + 'table' => array( + 'group' => $entity_type->getLabel(), + 'entity type' => $entity_type_id, + ), + ); + + $entity_field_manager = \Drupal::getContainer()->get('entity_field.manager'); + $bundle_info = \Drupal::getContainer()->get('entity_type.bundle.info'); + $properties = $entity_field_manager->getBaseFieldDefinitions($entity_type_id); + foreach (array_keys($bundle_info->getBundleInfo($entity_type_id)) as $bundle_id) { + $additional = $entity_field_manager->getFieldDefinitions($entity_type_id, $bundle_id); + $properties += $additional; + } + _search_api_views_add_handlers_for_properties($properties, $table, $data); + + return $table; +} + +/** + * Adds field and relationship handlers for the given properties. + * + * @param \Drupal\Core\TypedData\DataDefinitionInterface[] $properties + * The properties for which handlers should be added. + * @param array $table + * The existing Views data table definition, passed by reference. + * @param array $data + * The existing Views data definitions, passed by reference. + */ +function _search_api_views_add_handlers_for_properties(array $properties, array &$table, array &$data) { + $entity_reference_types = array_flip(array( + 'field_item:entity_reference', + 'field_item:image', + 'field_item:file', + )); + + foreach ($properties as $property_path => $property) { + $key = _search_api_views_find_field_alias($property_path, $table); + $original_property = $property; + $property = Utility::getInnerProperty($property); + + // Add a field handler, if applicable. + $definition = _search_api_views_get_field_handler_for_property($property); + if ($definition) { + $table[$key]['field'] = $definition; + } + + // For entity-typed properties, also add a relationship to the entity type + // table. + if ($property instanceof FieldItemDataDefinition && isset($entity_reference_types[$property->getDataType()])) { + $entity_type_id = $property->getSetting('target_type'); + if ($entity_type_id) { + $entity_type_table_key = 'search_api_entity_' . $entity_type_id; + if (!isset($data[$entity_type_table_key])) { + // Initialize the table definition to avoid infinite recursion in this + // line of code. + $data[$entity_type_table_key] = array(); + $data[$entity_type_table_key] = _search_api_views_entity_type_table($entity_type_id, $data); + } + // Add the relationship only if we have a non-empty table definition. + if ($data[$entity_type_table_key]) { + $entity_type = \Drupal::entityTypeManager() + ->getDefinition($entity_type_id); + $entity_type_label = $entity_type ? $entity_type->getLabel() : $entity_type_id; + $args = array( + '@label' => $entity_type_label, + '@field_name' => $original_property->getLabel(), + ); + $table[$key]['relationship'] = array( + 'title' => t('@label referenced from @field_name', $args), + 'label' => t('@field_name: @label', $args), + 'help' => $property->getDescription(), + 'id' => 'search_api', + 'base' => $entity_type_table_key, + 'entity type' => $entity_type_id, + ); + if ($key != $property_path) { + $field_definition['real field'] = $property_path; + } + } + } + } + + if (!empty($table[$key]) && empty($table[$key]['title'])) { + $table[$key]['title'] = $original_property->getLabel(); + $table[$key]['help'] = $original_property->getDescription(); + } + } +} + +/** + * Computes a handler definition for the given property. + * + * @param \Drupal\Core\TypedData\DataDefinitionInterface $property + * The property definition. + * + * @return array|null + * Either a Views field handler definition for this property, or NULL if the + * property shouldn't have one. + * + * @see hook_search_api_views_field_handler_mapping_alter() + */ +function _search_api_views_get_field_handler_for_property(DataDefinitionInterface $property) { + $mapping = &drupal_static(__FUNCTION__); + + if (!isset($mapping)) { + $mapping = array(); + +// $mapping['field_item:text_long'] = +// $mapping['field_item:text_with_summary'] = array( +// 'id' => 'search_api', +// 'html' => TRUE, +// ); +// +// $mapping['field_item:string_long'] = +// $mapping['field_item:string'] = +// $mapping['field_item:path'] = +// $mapping['email'] = +// $mapping['uri'] = +// $mapping['filter_format'] = +// $mapping['duration_iso8601'] = array( +// 'id' => 'search_api', +// ); +// +// $mapping['integer'] = +// $mapping['timespan'] = array( +// 'id' => 'search_api_numeric', +// ); +// +// $mapping['decimal'] = +// $mapping['float'] = array( +// 'id' => 'search_api_numeric', +// 'float' => TRUE, +// ); +// +// $mapping['field_item:created'] = +// $mapping['field_item:changed'] = +// $mapping['datetime_iso8601'] = +// $mapping['timestamp'] = array( +// 'id' => 'search_api_date', +// ); +// +// $mapping['boolean'] = +// $mapping['field_item:boolean'] = array( +// 'id' => 'search_api_boolean', +// ); +// +// $mapping['field_item:entity_reference'] = +// $mapping['field_item:comment'] = array( +// 'id' => 'search_api_entity', +// ); + + $alter_id = 'search_api_views_field_handler_mapping'; + \Drupal::moduleHandler()->alter($alter_id, $mapping); + } + + $data_type = $property->getDataType(); + if (array_key_exists($data_type, $mapping)) { + $definition = $mapping[$data_type]; + } + else { + $definition = array( + 'id' => 'search_api', + ); + } + + return $definition; +} diff --git a/search_api_db/search_api_db_defaults/config/optional/views.view.search_content.yml b/search_api_db/search_api_db_defaults/config/optional/views.view.search_content.yml index 9404bd0..14154b7 100644 --- a/search_api_db/search_api_db_defaults/config/optional/views.view.search_content.yml +++ b/search_api_db/search_api_db_defaults/config/optional/views.view.search_content.yml @@ -33,7 +33,6 @@ display: type: search_api_query options: search_api_bypass_access: false - entity_access: false parse_mode: terms exposed_form: type: basic diff --git a/src/Plugin/views/SearchApiHandlerTrait.php b/src/Plugin/views/SearchApiHandlerTrait.php new file mode 100644 index 0000000..773b9c0 --- /dev/null +++ b/src/Plugin/views/SearchApiHandlerTrait.php @@ -0,0 +1,54 @@ +getQuery()) { + return $this->getQuery()->getIndex(); + } + $base_table = $this->view->storage->get('base_table'); + return SearchApiQuery::getIndexFromTable($base_table); + } + + /** + * Retrieves the query plugin. + * + * @return \Drupal\search_api\Plugin\views\query\SearchApiQuery|null + * The query plugin, or NULL if there is no query or it is no Search API query. + */ + public function getQuery() { + if (empty($this->query) || !($this->query instanceof SearchApiQuery)) { + return NULL; + } + return $this->query; + } + +} diff --git a/src/Plugin/views/field/SearchApiExcerpt.php b/src/Plugin/views/field/SearchApiExcerpt.php deleted file mode 100644 index 83528dc..0000000 --- a/src/Plugin/views/field/SearchApiExcerpt.php +++ /dev/null @@ -1,30 +0,0 @@ -getValue($values); - return $this->sanitizeValue($value, 'xss'); - } - -} diff --git a/src/Plugin/views/field/SearchApiField.php b/src/Plugin/views/field/SearchApiField.php new file mode 100644 index 0000000..0f6c650 --- /dev/null +++ b/src/Plugin/views/field/SearchApiField.php @@ -0,0 +1,225 @@ +definition['click sortable']); + } + + /** + * {@inheritdoc} + */ + public function query() { + $this->addRetrievedProperty($this->getCombinedPropertyPath()); + } + + /** + * Adds a property to be retrieved. + * + * @param string $combined_property_path + * The combined property path of the property that should be retrieved. + * "_object" can be used as a property name to indicate the loaded object is + * required. + * + * @return $this + */ + protected function addRetrievedProperty($combined_property_path) { + $this->getQuery()->addRetrievedProperty($combined_property_path); + + list($datasource_id, $property_path) = Utility::splitCombinedId($combined_property_path); + $this->retrievedProperties[$datasource_id][$property_path] = $combined_property_path; + return $this; + } + + /** + * {@inheritdoc} + */ + public function preRender(&$values) { + // Determine which rows need to be considered (because they have the right + // datasource), which have or don't have all necessary properties already + // set and for which of them the items need to be loaded. + // @todo Should attempt to also multi-load related entities, not just the + // direct result objects. Much more complicated, though, since knowing the + // IDs of related entities requires the loaded parent objects. + $index = $this->getIndex(); + $items_to_load = array(); + $missing_properties = array(); + foreach ($values as $i => $row) { + if (!$this->isActiveForRow($row)) { + continue; + } + + $datasource_id = $row->search_api_datasource; + + $missing_properties[$i] = array(); + if (!empty($this->retrievedProperties[$datasource_id])) { + $missing_properties[$i] = array_flip($this->retrievedProperties[$datasource_id]); + } + $missing_properties[$i] = array_diff_key($missing_properties[$i], (array) $row); + + if ($missing_properties[$i] && empty($row->_object)) { + $items_to_load[$i] = $row->search_api_id; + } + } + + $items = $index->loadItemsMultiple($items_to_load); + foreach ($items_to_load as $i => $item_id) { + if (isset($items[$item_id])) { + $values[$i]->_object = $items[$item_id]; + $values[$i]->_item->setOriginalObject($items[$item_id]); + } + } + + foreach ($missing_properties as $i => $properties) { + if (empty($values[$i]->_object)) { + continue; + } + /** @var \Drupal\search_api\Item\FieldInterface[] $to_extract */ + $to_extract = array(); + foreach ($properties as $combined_property_path => $property_path) { + $field = Utility::createField($index, $combined_property_path) + ->setPropertyPath($property_path); + $to_extract[$property_path] = $field; + } + Utility::extractFields($values[$i]->_object, $to_extract); + foreach ($to_extract as $field) { + $combined_property_path = $field->getFieldIdentifier(); + $values[$i]->$combined_property_path = $field->getValues(); + } + } + } + + /** + * Determines whether this field is active for the given row. + * + * This is usually determined by the row's datasource. + * + * @param \Drupal\views\ResultRow $row + * The result row. + * + * @return bool + * TRUE if this field handler might produce output for the given row, FALSE + * otherwise. + */ + protected function isActiveForRow(ResultRow $row) { + $datasource_id = $this->getDatasourceId(); + return !$datasource_id || $row->search_api_datasource === $datasource_id; + } + + /** + * Retrieves the combined property path of this field. + * + * @return string + * The combined property path. + */ + protected function getCombinedPropertyPath() { + if (!isset($this->combinedPropertyPath)) { + // Add the property path of any relationships used to arrive at this + // field. + $path = $this->realField; + $relationships = $this->view->relationship; + $relationship = $this; + while (!empty($relationship->options['relationship'])) { + if (empty($relationships[$relationship->options['relationship']])) { + break; + } + $relationship = $relationships[$relationship->options['relationship']]; + // @todo Dirty hack. + $path = $relationship->realField . ':entity:' . $path; + } + $this->combinedPropertyPath = $path; + } + return $this->combinedPropertyPath; + } + + /** + * Retrieves the ID of the datasource to which this field belongs. + * + * @return string|null + * The datasource ID of this field, or NULL if it doesn't belong to a + * specific datasource. + */ + protected function getDatasourceId() { + if (!isset($this->datasourceId)) { + list($this->datasourceId) = Utility::splitCombinedId($this->getCombinedPropertyPath()); + } + return $this->datasourceId; + } + + /** + * {@inheritdoc} + */ + public function render_item($count, $item) { + return $item['value']; + } + + /** + * {@inheritdoc} + */ + public function getItems(ResultRow $values) { + $property_path = $this->getCombinedPropertyPath(); + if (!empty($values->{$property_path})) { + // Although it's undocumented, the field handler base class assumes items + // will always be arrays. + $items = array(); + foreach ((array) $values->{$property_path} as $value) { + $items[] = array( + 'value' => $value, + ); + } + return $items; + } + return array(); + } + +} diff --git a/src/Plugin/views/filter/SearchApiFilterTrait.php b/src/Plugin/views/filter/SearchApiFilterTrait.php index 9b12f26..2065593 100644 --- a/src/Plugin/views/filter/SearchApiFilterTrait.php +++ b/src/Plugin/views/filter/SearchApiFilterTrait.php @@ -8,21 +8,14 @@ namespace Drupal\search_api\Plugin\views\filter; use Drupal\Core\Form\FormStateInterface; -use Drupal\search_api\Plugin\views\query\SearchApiQuery; +use Drupal\search_api\Plugin\views\SearchApiHandlerTrait; /** * Provides a trait to use for Search API Views filters. */ trait SearchApiFilterTrait { - /** - * Overrides the Views handlers' ensureMyTable() method. - * - * This is done since adding a table to a Search API query is neither - * necessary nor possible, but we still want to stay as compatible as possible - * to the default SQL query plugin. - */ - public function ensureMyTable() {} + use SearchApiHandlerTrait; /** * Adds a form for entering the value or values for the filter. @@ -43,21 +36,6 @@ trait SearchApiFilterTrait { } /** - * Returns the active search index. - * - * @return \Drupal\search_api\IndexInterface|null - * The search index to use with this filter, or NULL if none could be - * loaded. - */ - protected function getIndex() { - if ($this->getQuery()) { - return $this->getQuery()->getIndex(); - } - $base_table = $this->view->storage->get('base_table'); - return SearchApiQuery::getIndexFromTable($base_table); - } - - /** * Adds a filter to the search query. * * Overridden to avoid errors because of SQL-specific functionality being used @@ -79,14 +57,4 @@ trait SearchApiFilterTrait { $this->getQuery()->addConditionGroup($filter, $this->options['group']); } - /** - * Retrieves the query plugin. - * - * @return \Drupal\search_api\Plugin\views\query\SearchApiQuery - * The query plugin. - */ - public function getQuery() { - return $this->query; - } - } diff --git a/src/Plugin/views/join/SearchApiJoin.php b/src/Plugin/views/join/SearchApiJoin.php new file mode 100644 index 0000000..aea83f0 --- /dev/null +++ b/src/Plugin/views/join/SearchApiJoin.php @@ -0,0 +1,30 @@ +retrievedProperties = array(); - $this->index = self::getIndexFromTable($view->storage->get('base_table')); + $this->index = static::getIndexFromTable($view->storage->get('base_table')); if (!$this->index) { $this->abort(new FormattableMarkup('View %view is not based on Search API but tries to use its query plugin.', array('%view' => $view->storage->label()))); } + $this->retrievedProperties = array_fill_keys($this->index->getDatasourceIds(), array()); + $this->retrievedProperties[NULL] = array(); $this->query = $this->index->query(); $this->query->setParseMode($this->options['parse_mode']); $this->query->addTag('views'); @@ -162,17 +163,50 @@ class SearchApiQuery extends QueryPluginBase { /** * Adds a property to be retrieved. * + * Currently doesn't serve any purpose, but might be added to the search query + * in the future to help backends that support returning fields determine + * which of the fields should actually be returned. + * * @param string $combined_property_path * The combined property path of the property that should be retrieved. * * @return $this */ - public function addField($combined_property_path) { - $this->retrievedProperties[$combined_property_path] = TRUE; + public function addRetrievedProperty($combined_property_path) { + list($datasource_id, $property_path) = Utility::splitCombinedId($combined_property_path); + $this->retrievedProperties[$datasource_id][$property_path] = $combined_property_path; return $this; } /** + * Adds a field to the table. + * + * This replicates the interface of Views' default SQL backend to simplify + * the Views integration of the Search API. If you are writing Search + * API-specific Views code, you should better use the addRetrievedProperty() + * method. + * + * @param string|null $table + * Ignored. + * @param string $field + * The combined property path of the property that should be retrieved. + * @param string $alias + * (optional) Ignored. + * @param array $params + * (optional) Ignored. + * + * @return string + * The name that this field can be referred to as (always $field). + * + * @see \Drupal\views\Plugin\views\query\Sql::addField() + * @see \Drupal\search_api\Plugin\views\query\SearchApiQuery::addField() + */ + public function addField($table, $field, $alias = '', $params = array()) { + $this->addRetrievedProperty($field); + return $field; + } + + /** * {@inheritdoc} */ public function defineOptions() { @@ -441,89 +475,31 @@ class SearchApiQuery extends QueryPluginBase { * The executed view. */ protected function addResults(array $results, ViewExecutable $view) { - /** @var \Drupal\views\ResultRow[] $rows */ - $rows = array(); - $missing = array(); - - if (!empty($this->configuration['entity_access'])) { - $items = $this->index->loadItemsMultiple(array_keys($results)); - $results = array_intersect_key($results, $items); - /** @var \Drupal\Core\Entity\Plugin\DataType\EntityAdapter $item */ - foreach ($items as $item_id => $item) { - if (!$item->getValue()->access('view')) { - unset($results[$item_id]); - } - } - } + // Views \Drupal\views\Plugin\views\style\StylePluginBase::renderFields() + // uses a numeric results index to key the rendered results. + // The ResultRow::index property is the key then used to retrieve these. + $count = 0; - // First off, we try to gather as much property values as possible without - // loading any items. foreach ($results as $item_id => $result) { - $datasource_id = $result->getDatasourceId(); - - // @todo Find a more elegant way of passing metadata here. + $values = array(); + $values['_item'] = $result; + $values['_object'] = $result->getOriginalObject(FALSE); $values['search_api_id'] = $item_id; - $values['search_api_datasource'] = $datasource_id; - - // Include the loaded item for this result row, if present, or the item - // ID. - $values['_item'] = $result->getOriginalObject(FALSE) ?: $item_id; - + $values['search_api_datasource'] = $result->getDatasourceId(); $values['search_api_relevance'] = $result->getScore(); $values['search_api_excerpt'] = $result->getExcerpt() ?: ''; - // Gather any fields from the search results. - foreach ($result->getFields(FALSE) as $field) { + // Gather any properties from the search results. + foreach ($result->getFields(FALSE) as $field_id => $field) { if ($field->getValues()) { - $combined_id = Utility::createCombinedId($field->getDatasourceId(), $field->getPropertyPath()); - $values[$combined_id] = $field->getValues(); - } - } - - // Check whether we need to extract any properties from the result item. - $missing_fields = array_diff_key($this->retrievedProperties, $values); - if ($missing_fields) { - $missing[$item_id] = array_keys($missing_fields); - if (!is_object($values['_item'])) { - $item_ids[] = $item_id; + $values[$field->getCombinedPropertyPath()] = $field->getValues(); } } - // Save the row values for adding them to the Views result afterwards. - // @todo Use a custom sub-class here to also pass the result item object, - // or other information? - $rows[$item_id] = new ResultRow($values); - } - - // Load items of those rows which haven't got all field values, yet. - if (!empty($item_ids)) { - foreach ($this->index->loadItemsMultiple($item_ids) as $item_id => $object) { - $results[$item_id]->setOriginalObject($object); - $rows[$item_id]->_item = $object; - } - } + $values['index'] = $count++; - foreach ($missing as $item_id => $missing_fields) { - /** @var \Drupal\search_api\Item\FieldInterface[] $fields_to_extract */ - $fields_to_extract = array(); - foreach ($missing_fields as $combined_id) { - list($datasource_id, $property_path) = Utility::splitCombinedId($combined_id); - if ($datasource_id == $results[$item_id]->getDatasourceId()) { - $fields_to_extract[$property_path] = Utility::createField($this->index, $combined_id); - } - } - Utility::extractFields($results[$item_id]->getOriginalObject(), $fields_to_extract); - foreach ($fields_to_extract as $field) { - $combined_id = $field->getFieldIdentifier(); - $rows[$item_id]->$combined_id = $field->getValues(); - } + $view->result[] = new ResultRow($values); } - - // Finally, add all rows to the Views result set. - $view->result = array_values($rows); - array_walk($view->result, function (ResultRow $row, $index) { - $row->index = $index; - }); } /** @@ -1119,6 +1095,7 @@ class SearchApiQuery extends QueryPluginBase { * concept of "tables", this method implementation does nothing. If you are * writing Search API-specific Views code, there is therefore no reason at all * to call this method. + * See https://www.drupal.org/node/2484565 for more information. * * @return string * An empty string. diff --git a/src/Plugin/views/relationship/SearchApiRelationship.php b/src/Plugin/views/relationship/SearchApiRelationship.php new file mode 100644 index 0000000..df71e6b --- /dev/null +++ b/src/Plugin/views/relationship/SearchApiRelationship.php @@ -0,0 +1,52 @@ +definition['entity type'])) { + $entity_type = \Drupal::entityTypeManager()->getDefinition($this->definition['entity type']); + if ($entity_type) { + $dependencies['module'][] = $entity_type->getProvider(); + } + } + + return $dependencies; + } + +} diff --git a/src/Plugin/views/row/SearchApiRow.php b/src/Plugin/views/row/SearchApiRow.php index b0ba304..a7e881d 100644 --- a/src/Plugin/views/row/SearchApiRow.php +++ b/src/Plugin/views/row/SearchApiRow.php @@ -24,7 +24,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * * @ViewsRow( * id = "search_api", - * title = @Translation("Rendered Search API item"), + * title = @Translation("Rendered entity"), * help = @Translation("Displays entity of the matching search API item"), * ) */ @@ -118,7 +118,6 @@ class SearchApiRow extends RowPluginBase { */ public function init(ViewExecutable $view, DisplayPluginBase $display, array &$options = NULL) { parent::init($view, $display, $options); - $base_table = $view->storage->get('base_table'); $this->index = SearchApiQuery::getIndexFromTable($base_table, $this->getEntityTypeManager()); if (!$this->index) { @@ -205,10 +204,31 @@ class SearchApiRow extends RowPluginBase { /** * {@inheritdoc} */ + public function preRender($result) { + // Load all result objects at once, before rendering. + $items_to_load = array(); + foreach ($result as $i => $row) { + if (empty($row->_object)) { + $items_to_load[$i] = $row->search_api_id; + } + } + + $items = $this->index->loadItemsMultiple($items_to_load); + foreach ($items_to_load as $i => $item_id) { + if (isset($items[$item_id])) { + $result[$i]->_object = $items[$item_id]; + $result[$i]->_item->setOriginalObject($items[$item_id]); + } + } + } + + /** + * {@inheritdoc} + */ public function render($row) { $datasource_id = $row->search_api_datasource; - if (!($row->_item instanceof ComplexDataInterface)) { + if (!($row->_object instanceof ComplexDataInterface)) { $context = array( '%item_id' => $row->search_api_id, '%view' => $this->view->storage->label(), @@ -228,13 +248,13 @@ class SearchApiRow extends RowPluginBase { // Always use the default view mode if it was not set explicitly in the // options. $view_mode = 'default'; - $bundle = $this->index->getDatasource($datasource_id)->getItemBundle($row->_item); + $bundle = $this->index->getDatasource($datasource_id)->getItemBundle($row->_object); if (isset($this->options['view_modes'][$datasource_id][$bundle])) { $view_mode = $this->options['view_modes'][$datasource_id][$bundle]; } try { - return $this->index->getDatasource($datasource_id)->viewItem($row->_item, $view_mode); + return $this->index->getDatasource($datasource_id)->viewItem($row->_object, $view_mode); } catch (SearchApiException $e) { watchdog_exception('search_api', $e); @@ -245,10 +265,6 @@ class SearchApiRow extends RowPluginBase { /** * {@inheritdoc} */ - public function query() { - parent::query(); - // @todo Find a better way to ensure that the item is loaded. - $this->view->query->addField('_magic'); - } + public function query() {} } diff --git a/src/Tests/ViewsTest.php b/src/Tests/ViewsTest.php index e89c56c..40befe4 100644 --- a/src/Tests/ViewsTest.php +++ b/src/Tests/ViewsTest.php @@ -24,7 +24,7 @@ class ViewsTest extends WebTestBase { * * @var string[] */ - public static $modules = array('search_api_test_views'); + public static $modules = array('search_api_test_views', 'views_ui'); /** * A search index ID. @@ -182,4 +182,19 @@ class ViewsTest extends WebTestBase { } } + /** + * Test views admin. + */ + public function testViewsAdmin() { + $admin_user = $this->drupalCreateUser(array( + 'administer search_api', + 'access administration pages', + 'administer views', + )); + $this->drupalLogin($admin_user); + $this->insertExampleContent(); + + $this->drupalGet('admin/structure/views/view/search_api_test_views_fulltext'); + } + } diff --git a/src/Utility.php b/src/Utility.php index 15dccc4..363dd93 100644 --- a/src/Utility.php +++ b/src/Utility.php @@ -26,6 +26,7 @@ use Drupal\search_api\Item\Item; use Drupal\search_api\Query\Query; use Drupal\search_api\Query\QueryInterface; use Drupal\search_api\Query\ResultSet; +use Symfony\Component\DependencyInjection\Container; /** * Contains utility methods for the Search API. @@ -503,11 +504,7 @@ class Utility { $field = new Field($index, $field_identifier); foreach ($field_info as $key => $value) { - // Unfortunately, the $delimiters parameter for ucwords() wasn't - // introduced until PHP 5.5.16 and thus cannot be depended upon. - $method = str_replace('_', ' ', $key); - $method = ucwords($method); - $method = 'set' . str_replace(' ', '', $method); + $method = 'set' . Container::camelize($key); if (method_exists($field, $method)) { $field->$method($value); } diff --git a/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml b/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml index 0031593..a397f64 100644 --- a/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml +++ b/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml @@ -20,7 +20,6 @@ display: type: search_api_query options: search_api_bypass_access: false - entity_access: false parse_mode: terms exposed_form: type: basic