diff --git a/config/schema/search_api.views.schema.yml b/config/schema/search_api.views.schema.yml index 93a2cdf..e235208 100644 --- a/config/schema/search_api.views.schema.yml +++ b/config/schema/search_api.views.schema.yml @@ -2,12 +2,12 @@ views.query.search_api_query: type: views_query label: 'Search API query' mapping: - search_api_bypass_access: + 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: + skip_access: type: boolean - label: Execute an access check for all result entities. + label: Do not execute additional access checks for all entities in the search results. parse_mode: type: string label: Chooses how the search keys will be parsed. @@ -37,6 +37,100 @@ views.argument.search_api_more_like_this: type: string label: 'Field' +views.field.search_api: + type: views_field + label: 'Search API standard' + mapping: + link_to_item: + type: boolean + label: 'Link to item' + multi_type: + type: string + label: 'Handling of multiple values' + multi_separator: + type: string + label: 'Separator for multiple values' + +views.field.search_api_boolean: + type: views.field.boolean + label: 'Search API boolean' + mapping: + link_to_item: + type: boolean + label: 'Link to item' + multi_type: + type: string + label: 'Handling of multiple values' + multi_separator: + type: string + label: 'Separator for multiple values' + +views.field.search_api_date: + type: views.field.date + label: 'Search API date' + mapping: + link_to_item: + type: boolean + label: 'Link to item' + multi_type: + type: string + label: 'Handling of multiple values' + multi_separator: + type: string + label: 'Separator for multiple values' + +views.field.search_api_entity: + type: views.field.search_api + label: 'Search API entity reference' + mapping: + display_methods: + type: sequence + label: 'Display settings' + sequence: + type: mapping + label: 'Display settings for bundle' + mapping: + display_method: + type: string + label: 'Display method' + view_mode: + type: string + label: 'View mode' + +views.field.search_api_field: + type: views.field.field + label: 'Search API entity field' + mapping: + field_rendering: + type: boolean + label: 'Use entity field rendering' + fallback_handler: + type: string + label: 'Fallback handler' + fallback_options: + type: views.field.[%parent.fallback_handler] + label: 'Options for fallback handler' + +views.field.search_api_numeric: + type: views.field.numeric + label: 'Search API boolean' + mapping: + link_to_item: + type: boolean + label: 'Link to item' + multi_type: + type: string + label: 'Handling of multiple values' + multi_separator: + type: string + label: 'Separator for multiple values' + format_plural_values: + type: sequence + label: 'Pluralized strings' + sequence: + type: string + label: 'Singular/Plural string' + views.filter.search_api_boolean: type: views.filter.boolean label: 'Search API boolean' diff --git a/search_api.api.php b/search_api.api.php index 55eaee4..696be2b 100644 --- a/search_api.api.php +++ b/search_api.api.php @@ -100,21 +100,23 @@ function hook_search_api_field_type_mapping_alter(array &$mapping) { /** * Alter the mapping of Search API data types to their default Views handlers. * + * Field handlers are not determined by these simplified (Search API) types, but + * by their actual property data types. For altering that mapping, see + * hook_search_api_views_field_handler_mapping_alter(). + * * @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( 'argument' => array( 'id' => 'my_entity_type', ), - 'field' => array( - 'id' => 'my_entity_type', - ), 'filter' => array( 'id' => 'my_entity_type', ), @@ -126,6 +128,37 @@ 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. + * + * In addition to the definition returned here, for Field API fields, the + * "field_name" will be set to the field's machine name. + * + * @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. The data types in the keys might also contain asterisks (*) as + * wildcard characters. Data types with wildcards will be matched only if no + * specific type exists, and longer type patterns will be tried before shorter + * ones. The "*" mapping therefore is the default if no other match could be + * found. + */ +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..102f0f0 100644 --- a/search_api.views.inc +++ b/search_api.views.inc @@ -5,7 +5,11 @@ * 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\Core\TypedData\DataReferenceDefinitionInterface; +use Drupal\search_api\Datasource\DatasourceInterface; use Drupal\search_api\Entity\Index; use Drupal\search_api\Item\FieldInterface; use Drupal\search_api\SearchApiException; @@ -13,35 +17,60 @@ 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 encountered in any table, a table with + * field/relationship handlers for all of that entity type's properties is + * created. 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(), $field->getPropertyPath()); + 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()) { - $field_definition['group'] = $datasource->label(); + $field_definition['group'] = t('@datasource datasource', array('@datasource' => $datasource->label())); } if ($definition = $field->getDataDefinition()) { $field_definition['title short'] = $definition->getLabel(); @@ -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 field)', 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,451 @@ 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' => t('@datasource datasource', array('@datasource' => $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; + $table['table']['entity revision'] = FALSE; + } + + _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); + + // Relationships sometimes have different real fields set, since they might + // also include the nested property that contains the actual reference. So, + // if a "real field" is set for that, we need to adapt it as well. + if (isset($definition['relationship']['real field'])) { + $real_field = $definition['relationship']['real field']; + $table[$key]['relationship']['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' => t('@entity_type relationship', array('@entity_type' => $entity_type->getLabel())), + 'entity type' => $entity_type_id, + 'entity revision' => FALSE, + ), + ); + + $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, $property_path); + 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 before calling + // _search_api_views_entity_type_table() to avoid an infinite + // recursion. + $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]) { + // Get the entity type to determine the label for the relationship. + $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(), + ); + // Look through the child properties to find the data reference + // property that should be the "real field" for the relationship. + // (For Core entity references, this will usually be ":entity".) + $suffix = ''; + foreach ($property->getPropertyDefinitions() as $name => $nested_property) { + if ($nested_property instanceof DataReferenceDefinitionInterface) { + $suffix = ":$name"; + break; + } + } + $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, + 'entity revision' => FALSE, + 'real field' => $property_path . $suffix, + ); + } + } + } + + if (!empty($table[$key]) && empty($table[$key]['title'])) { + $table[$key]['title'] = $original_property->getLabel(); + $table[$key]['help'] = $original_property->getDescription(); + if ($key != $property_path) { + $table[$key]['real field'] = $property_path; + } + } + } +} + +/** + * Computes a handler definition for the given property. + * + * @param \Drupal\Core\TypedData\DataDefinitionInterface $property + * The property definition. + * @param string|null $property_path + * (optional) The property path of the property. If set, it will be used for + * Field API fields to set the "field_name" property of the 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, $property_path = NULL) { + $mappings = _search_api_views_get_field_handler_mapping(); + + // First, look for an exact match. + $data_type = $property->getDataType(); + if (array_key_exists($data_type, $mappings['simple'])) { + $definition = $mappings['simple'][$data_type]; + } + else { + // Then check all the patterns defined by regular expressions, defaulting to + // the "default" definition. + $definition = $mappings['default']; + foreach (array_keys($mappings['regex']) as $regex) { + if (preg_match($regex, $data_type)) { + $definition = $mappings['regex'][$regex]; + } + } + } + + // If there is a definition and the property represents a Field API field, add + // the "field_name" key. + if (isset($definition) && $property instanceof FieldItemDataDefinition) { + list(, $field_name) = Utility::splitPropertyPath($property_path, TRUE); + $definition['field_name'] = $field_name; + $definition['entity_type'] = $property + ->getFieldDefinition() + ->getTargetEntityTypeId(); + } + + return $definition; +} + +/** + * Retrieves the field handler mapping used by the Search API Views integration. + * + * @return array + * An associative array with three keys: + * - simple: An associative array mapping property data types to their field + * handler definitions. + * - regex: An array associative array mapping regular expressions for + * property data types to their field handler definitions, ordered by + * descending string length of the regular expression. + * - default: The default definition for data types that match no other field. + */ +function _search_api_views_get_field_handler_mapping() { + $mappings = &drupal_static(__FUNCTION__); + + if (!isset($mappings)) { + // First create a plain mapping and pass it to the alter hook. + $plain_mapping = array(); + + $plain_mapping['*'] = array( + 'id' => 'search_api', + ); + + $plain_mapping['field_item:text_long'] = + $plain_mapping['field_item:text_with_summary'] = array( + 'id' => 'search_api', + 'filter_type' => 'xss', + ); + + $plain_mapping['field_item:integer'] = + $plain_mapping['field_item:list_integer'] = + $plain_mapping['integer'] = + $plain_mapping['timespan'] = array( + 'id' => 'search_api_numeric', + ); + + $plain_mapping['field_item:decimal'] = + $plain_mapping['field_item:float'] = + $plain_mapping['field_item:list_float'] = + $plain_mapping['decimal'] = + $plain_mapping['float'] = array( + 'id' => 'search_api_numeric', + 'float' => TRUE, + ); + + $plain_mapping['field_item:created'] = + $plain_mapping['field_item:changed'] = + $plain_mapping['datetime_iso8601'] = + $plain_mapping['timestamp'] = array( + 'id' => 'search_api_date', + ); + + $plain_mapping['boolean'] = + $plain_mapping['field_item:boolean'] = array( + 'id' => 'search_api_boolean', + ); + + $plain_mapping['field_item:entity_reference'] = + $plain_mapping['field_item:comment'] = array( + 'id' => 'search_api_entity', + ); + + // Field items have a special handler, but need a fallback handler set to be + // able to optionally circumvent entity field rendering. That's why we just + // set the "field_item:…" types to their fallback handlers above, along with + // non-field item types, and here go through them again to add the proper + // handler ("search_api_field") under "id" and move the previous one to + // "fallback_handler". + foreach ($plain_mapping as $key => $definition) { + if (substr($key, 0, 11) == 'field_item:') { + $plain_mapping[$key]['fallback_handler'] = $definition['id']; + $plain_mapping[$key]['id'] = 'search_api_field'; + } + } + + // Finally, set a default handler for unknown field items. + $plain_mapping['field_item:*'] = array( + 'id' => 'search_api_field', + 'fallback_handler' => 'search_api', + ); + + // Let other modules change or expand this mapping. + $alter_id = 'search_api_views_field_handler_mapping'; + \Drupal::moduleHandler()->alter($alter_id, $plain_mapping); + + // Then create a new, more practical structure, with the mappings grouped by + // mapping type. + $mappings = array( + 'simple' => array(), + 'regex' => array(), + 'default' => NULL, + ); + foreach ($plain_mapping as $type => $definition) { + if ($type == '*') { + $mappings['default'] = $definition; + } + elseif (strpos($type, '*') === FALSE) { + $mappings['simple'][$type] = $definition; + } + else { + // Transform the type into a PCRE regular expression, taking care to + // quote everything except for the wildcards. + $parts = explode('*', $type); + // Passing the second parameter to preg_quote() is a bit tricky with + // array_map(), we need to construct an array of slashes. + $slashes = array_fill(0, count($parts), '/'); + $parts = array_map('preg_quote', $parts, $slashes); + // Use the "S" modifier for closer analysis of the pattern, since it + // might be executed a lot. + $regex = '/^' . implode('.*', $parts) . '$/S'; + $mappings['regex'][$regex] = $definition; + } + } + // Finally, order the regular expressions descending by their lengths. + $compare = function ($a, $b) { + return strlen($b) - strlen($a); + }; + uksort($mappings['regex'], $compare); + } + + return $mappings; +} 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..519fc7a 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 @@ -32,8 +32,7 @@ display: query: type: search_api_query options: - search_api_bypass_access: false - entity_access: false + bypass_access: false parse_mode: terms exposed_form: type: basic diff --git a/src/Plugin/views/EntityFieldRenderer.php b/src/Plugin/views/EntityFieldRenderer.php new file mode 100644 index 0000000..232c478 --- /dev/null +++ b/src/Plugin/views/EntityFieldRenderer.php @@ -0,0 +1,93 @@ +datasourceId; + } + + /** + * Sets the datasource ID. + * + * @param string|null $datasource_id + * The new datasource ID. + * + * @return $this + */ + public function setDatasourceId($datasource_id) { + $this->datasourceId = $datasource_id; + return $this; + } + + /** + * Determines whether this renderer can handle the given field. + * + * @param \Drupal\views\Plugin\views\field\FieldHandlerInterface $field + * The field for which to check compatibility. + * + * @return bool + * TRUE if this renderer can handle the given field, FALSE otherwise. + * + * @see EntityFieldRenderer::getRenderableFieldIds() + */ + public function compatibleWithField(FieldHandlerInterface $field) { + if ($field instanceof SearchApiEntityField && $field->relationship == $this->relationship) { + // If there is no relationship set, we also need to compare the + // datasource ID. + if ($field->relationship || $field->getDatasourceId() == $this->datasourceId) { + return TRUE; + } + } + + return FALSE; + } + + /** + * {@inheritdoc} + */ + protected function getRenderableFieldIds() { + $field_ids = []; + foreach ($this->view->field as $field_id => $field) { + if ($this->compatibleWithField($field)) { + $field_ids[] = $field_id; + } + } + return $field_ids; + } + +} diff --git a/src/Plugin/views/SearchApiHandlerTrait.php b/src/Plugin/views/SearchApiHandlerTrait.php new file mode 100644 index 0000000..4b1032f --- /dev/null +++ b/src/Plugin/views/SearchApiHandlerTrait.php @@ -0,0 +1,72 @@ +definition['entity_type'])) { + return $this->definition['entity_type']; + } + return parent::getEntityType(); + } + + /** + * 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); + } + + /** + * 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/SearchApiBoolean.php b/src/Plugin/views/field/SearchApiBoolean.php new file mode 100644 index 0000000..6cbf79b --- /dev/null +++ b/src/Plugin/views/field/SearchApiBoolean.php @@ -0,0 +1,24 @@ +setEntityDisplayRepository($container->get('entity_display.repository')); + + return $field; + } + + /** + * Retrieves the entity display repository. + * + * @return \Drupal\Core\Entity\EntityDisplayRepositoryInterface + * The entity entity display repository. + */ + public function getEntityDisplayRepository() { + return $this->entityDisplayRepository ?: \Drupal::service('entity_display.repository'); + } + + /** + * Sets the entity display repository. + * + * @param \Drupal\Core\Entity\EntityDisplayRepositoryInterface $entity_display_repository + * The new entity display repository. + * + * @return $this + */ + public function setEntityDisplayRepository(EntityDisplayRepositoryInterface $entity_display_repository) { + $this->entityDisplayRepository = $entity_display_repository; + return $this; + } + + /** + * {@inheritdoc} + */ + public function defineOptions() { + $options = parent::defineOptions(); + + $options['display_methods'] = array('default' => array()); + + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + + $entity_type_id = $this->getTargetEntityTypeId(); + $view_modes = array(); + $bundles = array(); + if ($entity_type_id) { + $bundles = $this->getEntityManager()->getBundleInfo($entity_type_id); + // In case the field definition specifies the bundles to expect, restrict + // the displayed bundles to those. + $settings = $this->getFieldDefinition()->getSettings(); + if (!empty($settings['handler_settings']['target_bundles'])) { + $bundles = array_intersect_key($bundles, $settings['handler_settings']['target_bundles']); + } + foreach ($bundles as $bundle => $info) { + $view_modes[$bundle] = $this->getEntityDisplayRepository() + ->getViewModeOptionsByBundle($entity_type_id, $bundle); + } + } + + foreach ($bundles as $bundle => $info) { + $args['@bundle'] = $info['label']; + $form['display_methods'][$bundle]['display_method'] = array( + '#type' => 'select', + '#title' => $this->t('Display for "@bundle" bundle', $args), + '#options' => array( + '' => $this->t('Hide'), + 'label' => $this->t('Only label'), + ), + ); + if (isset($this->options['display_methods'][$bundle]['display_method'])) { + $form['display_methods'][$bundle]['display_method']['#default_value'] = $this->options['display_methods'][$bundle]['display_method']; + } + if (!empty($view_modes[$bundle])) { + $form['display_methods'][$bundle]['display_method']['#options']['view_mode'] = $this->t('Entity view'); + if (count($view_modes[$bundle]) > 1) { + $form['display_methods'][$bundle]['view_mode'] = array( + '#type' => 'select', + '#title' => $this->t('View mode for "@bundle" bundle', $args), + '#options' => $view_modes[$bundle], + '#states' => array( + 'visible' => array( + ':input[name="options[display_methods][' . $bundle . '][display_method]"]' => array( + 'value' => 'view_mode', + ), + ), + ), + ); + if (isset($this->options['display_methods'][$bundle]['view_mode'])) { + $form['display_methods'][$bundle]['view_mode']['#default_value'] = $this->options['display_methods'][$bundle]['view_mode']; + } + } + else { + reset($view_modes[$bundle]); + $form['display_methods'][$bundle]['view_mode'] = array( + '#type' => 'value', + '#value' => key($view_modes[$bundle]), + ); + } + } + if (count($bundles) == 1) { + $form['display_methods'][$bundle]['display_method']['#title'] = $this->t('Display method'); + if (!empty($form['display_methods'][$bundle]['view_mode'])) { + $form['display_methods'][$bundle]['view_mode']['#title'] = $this->t('View mode'); + } + } + } + + $form['link_to_item']['#description'] .= ' ' . $this->t('This will only take effect for entities for which only the entity label is displayed.'); + $form['link_to_item']['#weight'] = 5; + } + + /** + * Return the entity type ID of the entity this field handler should display. + * + * @return string|null + * The entity type ID, or NULL if it couldn't be found. + */ + public function getTargetEntityTypeId() { + $field_definition = $this->getFieldDefinition(); + if ($field_definition->getType() === 'field_item:comment') { + return 'comment'; + } + return $field_definition->getSetting('target_type'); + } + + /** + * {@inheritdoc} + */ + public function query() { + $this->addRetrievedProperty($this->getCombinedPropertyPath()); + } + + /** + * {@inheritdoc} + */ + public function preRender(&$values) { + parent::preRender($values); + + // The parent method will just have loaded the entity IDs. We now multi-load + // the actual objects. + $property_path = $this->getCombinedPropertyPath(); + foreach ($values as $i => $row) { + if (!empty($row->{$property_path})) { + foreach ((array) $row->{$property_path} as $j => $value) { + if (is_scalar($value)) { + $to_load[$value][] = array($i, $j); + } + } + } + } + + if (empty($to_load)) { + return; + } + + $entities = $this->getEntityManager() + ->getStorage($this->getTargetEntityTypeId()) + ->loadMultiple(array_keys($to_load)); + $account = $this->getQuery()->getAccessAccount(); + foreach ($entities as $id => $entity) { + foreach ($to_load[$id] as list($i, $j)) { + if ($entity->access('view', $account)) { + $values[$i]->{$property_path}[$j] = $entity; + } + } + } + } + + /** + * {@inheritdoc} + */ + public function render_item($count, $item) { + if (is_array($item['value'])) { + return $this->getRenderer()->render($item['value']); + } + return parent::render_item($count, $item); + } + + /** + * {@inheritdoc} + */ + public function getItems(ResultRow $values) { + $property_path = $this->getCombinedPropertyPath(); + if (!empty($values->{$property_path})) { + $items = array(); + foreach ((array) $values->{$property_path} as $value) { + if ($value instanceof EntityInterface) { + $item = $this->getItem($value); + if ($item) { + $items[] = $item; + } + } + } + return $items; + } + return array(); + } + + /** + * Creates an item for the given entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * + * @return array|null + * NULL if the entity should not be displayed. Otherwise, an associative + * array with at least "value" set, to either a string or a render array, + * and possibly also additional alter options. + */ + protected function getItem(EntityInterface $entity) { + $bundle = $entity->bundle(); + if (empty($this->options['display_methods'][$bundle]['display_method'])) { + return NULL; + } + + if ($this->options['display_methods'][$bundle]['display_method'] == 'label') { + $item['value'] = $entity->label(); + + if ($this->options['link_to_item']) { + $item['make_link'] = TRUE; + $item['url'] = $entity->toUrl('canonical'); + } + + return $item; + } + + $view_mode = $this->options['display_methods'][$bundle]['view_mode']; + $build = $this->getEntityManager() + ->getViewBuilder($entity->getEntityTypeId()) + ->view($entity, $view_mode); + return array( + 'value' => $build, + ); + } + +} diff --git a/src/Plugin/views/field/SearchApiEntityField.php b/src/Plugin/views/field/SearchApiEntityField.php new file mode 100644 index 0000000..d824289 --- /dev/null +++ b/src/Plugin/views/field/SearchApiEntityField.php @@ -0,0 +1,289 @@ +definition['fallback_handler']) ? $this->definition['fallback_handler'] : 'search_api'; + $this->fallbackHandler = Views::handlerManager('field') + ->getHandler($options, $fallback_handler_id); + $options += array('fallback_options' => array()); + $fallback_options = $options['fallback_options'] + $options; + $this->fallbackHandler->init($view, $display, $fallback_options); + + parent::init($view, $display, $options); + } + + /** + * {@inheritdoc} + */ + public function query() { + // If we're not using Field API field rendering, just use the query() + // implementation of the fallback handler. + if (!$this->options['field_rendering']) { + $this->fallbackHandler->query(); + return; + } + + // If we do use Field API rendering, we need the entity object for the + // parent property. + $parent_path = $this->getParentPath(); + $property_path = $parent_path ? "$parent_path:_object" : '_object'; + $combined_property_path = Utility::createCombinedId($this->getDatasourceId(), $property_path); + $this->addRetrievedProperty($combined_property_path); + } + + /** + * Retrieves the property path of the parent property. + * + * @return string|null + * The property path of the parent property. + */ + protected function getParentPath() { + if (!isset($this->parentPath)) { + $combined_property_path = $this->getCombinedPropertyPath(); + list(, $property_path) = Utility::splitCombinedId($combined_property_path); + list($this->parentPath) = Utility::splitPropertyPath($property_path); + } + + return $this->parentPath; + } + + /** + * {@inheritdoc} + */ + public function defineOptions() { + $options = parent::defineOptions(); + + $options['field_rendering'] = array('default' => TRUE); + $options['fallback_handler'] = array('default' => $this->fallbackHandler->getPluginId()); + $options['fallback_options'] = array('contains' => $this->fallbackHandler->defineOptions()); + + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + $form['field_rendering'] = array( + '#type' => 'checkbox', + '#title' => $this->t('Use entity field rendering'), + '#description' => $this->t("If checked, Drupal's built-in field rendering mechanism will be used for rendering this field's values, which requires the entity to be loaded. If unchecked, a type-specific, entity-independent rendering mechanism will be used."), + '#default_value' => $this->options['field_rendering'], + ); + + // Wrap the (immediate) parent options in their own field set, to clean up + // the UI when (un)checking the above checkbox. + $form['parent_options'] = array( + '#type' => 'fieldset', + '#title' => $this->t('Render settings'), + '#states' => array( + 'visible' => array( + ':input[name="options[field_rendering]"]' => array('checked' => TRUE), + ), + ), + ); + + // Include the parent options form and move all fields that were added by + // our direct parent (\Drupal\views\Plugin\views\field\Field) to the + // "parent_options" fieldset. + parent::buildOptionsForm($form, $form_state); + $parent_keys = array( + 'multiple_field_settings', + 'click_sort_column', + 'type', + 'field_api_classes', + 'settings', + ); + foreach ($parent_keys as $key) { + if (!empty($form[$key])) { + $form[$key]['#fieldset'] = 'parent_options'; + } + } + // The Core boolean formatter hard-codes the field name to "field_boolean". + // This breaks the parent class's call of rewriteStatesSelector() for fixing + // "#states". We therefore apply that behavior again here. + if (!empty($form['settings'])) { + FormHelper::rewriteStatesSelector($form['settings'], "fields[field_boolean][settings_edit_form]", 'options'); + } + + // Get the options form for the fallback handler. + $fallback_form = array(); + $this->fallbackHandler->buildOptionsForm($fallback_form, $form_state); + // Remove all fields from FieldPluginBase from the fallback form, but leave + // those in that were only added by our immediate parent, + // \Drupal\views\Plugin\views\field\Field. (E.g., the "type" option is + // especially prone to conflicts here.) The others come from the plugin base + // classes and will be identical, so it would be confusing to include them + // twice. + $parent_keys[] = '#pre_render'; + $remove_from_fallback = array_diff_key($form, array_flip($parent_keys)); + $fallback_form = array_diff_key($fallback_form, $remove_from_fallback); + // Fix the "#states" selectors in the fallback form, and put an additional + // "#states" directive on it to only be visible for the corresponding + // "field_rendering" setting. + if ($fallback_form) { + FormHelper::rewriteStatesSelector($fallback_form, '"options[', '"options[fallback_options]['); + $form['fallback_options'] = $fallback_form; + $form['fallback_options']['#type'] = 'fieldset'; + $form['fallback_options']['#title'] = $this->t('Render settings'); + $form['fallback_options']['#states']['visible'][':input[name="options[field_rendering]"]'] = array('checked' => FALSE); + } + } + + /** + * {@inheritdoc} + */ + public function preRender(&$values) { + if ($this->options['field_rendering']) { + $this->traitPreRender($values); + parent::preRender($values); + } + else { + $this->fallbackHandler->preRender($values); + } + } + + /** + * {@inheritdoc} + */ + public function render(ResultRow $values) { + if (!$this->options['field_rendering']) { + return $this->fallbackHandler->render($values); + } + return parent::render($values); + } + + /** + * {@inheritdoc} + */ + public function render_item($count, $item) { + if (!$this->options['field_rendering']) { + if ($this->fallbackHandler instanceof MultiItemsFieldHandlerInterface) { + return $this->fallbackHandler->render_item($count, $item); + } + return ''; + } + return parent::render_item($count, $item); + } + + /** + * {@inheritdoc} + */ + protected function getEntityFieldRenderer() { + if (!isset($this->entityFieldRenderer)) { + // This can be invoked during field handler initialization in which case + // view fields are not set yet. + if (!empty($this->view->field)) { + foreach ($this->view->field as $field) { + // An entity field renderer can handle only a single relationship. + if (isset($field->entityFieldRenderer)) { + if ($field->entityFieldRenderer instanceof EntityFieldRenderer && $field->entityFieldRenderer->compatibleWithField($this)) { + $this->entityFieldRenderer = $field->entityFieldRenderer; + break; + } + } + } + } + if (!isset($this->entityFieldRenderer)) { + $entity_type = $this->entityManager->getDefinition($this->getEntityType()); + $this->entityFieldRenderer = new EntityFieldRenderer($this->view, $this->relationship, $this->languageManager, $entity_type, $this->entityManager); + $this->entityFieldRenderer->setDatasourceId($this->getDatasourceId()); + } + } + + return $this->entityFieldRenderer; + } + + /** + * {@inheritdoc} + */ + public function getItems(ResultRow $values) { + if (!$this->options['field_rendering']) { + if ($this->fallbackHandler instanceof MultiItemsFieldHandlerInterface) { + return $this->fallbackHandler->getItems($values); + } + return array(); + } + + if ($values->search_api_datasource != $this->getDatasourceId()) { + return array(); + } + + $parent_path = $this->getParentPath(); + if (empty($values->_relationship_objects[$parent_path])) { + return array(); + } + $build = array(); + foreach (array_keys($values->_relationship_objects[$parent_path]) as $i) { + $this->valueIndex = $i; + $build[] = parent::getItems($values); + } + return $build ? call_user_func_array('array_merge', $build) : array(); + } + + /** + * {@inheritdoc} + */ + public function renderItems($items) { + if (!$this->options['field_rendering']) { + if ($this->fallbackHandler instanceof MultiItemsFieldHandlerInterface) { + return $this->fallbackHandler->renderItems($items); + } + return ''; + } + + return parent::renderItems($items); + } + +} diff --git a/src/Plugin/views/field/SearchApiExcerpt.php b/src/Plugin/views/field/SearchApiExcerpt.php deleted file mode 100644 index 8a78746..0000000 --- a/src/Plugin/views/field/SearchApiExcerpt.php +++ /dev/null @@ -1,33 +0,0 @@ -getValue($values); - return $this->sanitizeValue($value, 'xss'); - } - -} diff --git a/src/Plugin/views/field/SearchApiFieldTrait.php b/src/Plugin/views/field/SearchApiFieldTrait.php new file mode 100644 index 0000000..c38f0fc --- /dev/null +++ b/src/Plugin/views/field/SearchApiFieldTrait.php @@ -0,0 +1,636 @@ + array( + * - 'default' => default value, + * - 'contains' => (optional) array of items this contains, with its own + * defaults, etc. If contains is set, the default will be ignored and + * assumed to be array(). + * ), + * @endcode + * + * @return array + * Returns the options of this handler/plugin. + * + * @see \Drupal\views\Plugin\views\PluginBase::defineOptions() + */ + public function defineOptions() { + $options = parent::defineOptions(); + + $options['link_to_item'] = array('default' => FALSE); + + if ($this->isMultiple()) { + $options['multi_type'] = array('default' => 'separator'); + $options['multi_separator'] = array('default' => ', '); + } + + return $options; + } + + /** + * Provide a form to edit options for this plugin. + * + * @param array $form + * The existing form structure, passed by reference. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current form state. + * + * @see \Drupal\views\Plugin\views\ViewsPluginInterface::buildOptionsForm() + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + + $form['link_to_item'] = array( + '#type' => 'checkbox', + '#title' => $this->t('Link this field to its item'), + '#description' => $this->t('Display this field as a link to its original entity or item.'), + '#default_value' => $this->options['link_to_item'], + ); + + if ($this->isMultiple()) { + $form['multi_value_settings'] = array( + '#type' => 'details', + '#title' => $this->t('Multiple values handling'), + '#description' => $this->t('If this field contains multiple values for an item, these settings will determine how they are handled.'), + '#weight' => 80, + ); + + $form['multi_type'] = array( + '#type' => 'radios', + '#title' => $this->t('Display type'), + '#options' => array( + 'ul' => $this->t('Unordered list'), + 'ol' => $this->t('Ordered list'), + 'separator' => $this->t('Simple separator'), + ), + '#default_value' => $this->options['multi_type'], + '#fieldset' => 'multi_value_settings', + '#weight' => 0, + ); + $form['multi_separator'] = array( + '#type' => 'textfield', + '#title' => $this->t('Separator'), + '#default_value' => $this->options['multi_separator'], + '#states' => array( + 'visible' => array( + ':input[name="options[multi_type]"]' => array('value' => 'separator'), + ), + ), + '#fieldset' => 'multi_value_settings', + '#weight' => 1, + ); + } + } + + /** + * Determines if this field is click sortable. + * + * @return bool + * The value of "click sortable" from the plugin definition, this defaults + * to FALSE if not set. + * + * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::clickSortable() + */ + public function clickSortable() { + // Almost the same logic as in the parent class, but we want to default to + // FALSE. + return !empty($this->definition['click sortable']); + } + + /** + * Add anything to the query that we might need to. + * + * @see \Drupal\views\Plugin\views\ViewsPluginInterface::query() + */ + public function query() { + $combined_property_path = $this->getCombinedPropertyPath(); + $this->addRetrievedProperty($combined_property_path); + if ($this->options['link_to_item']) { + $this->addRetrievedProperty("$combined_property_path:_object"); + } + } + + /** + * 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; + } + + /** + * Gets the entity matching the current row and relationship. + * + * @param \Drupal\views\ResultRow $values + * An object containing all retrieved values. + * + * @return \Drupal\Core\Entity\EntityInterface + * Returns the entity matching the values. + * + * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::getEntity + */ + public function getEntity(ResultRow $values) { + list($datasource_id, $property_path) = Utility::splitCombinedId($this->getCombinedPropertyPath()); + + if ($values->search_api_datasource != $datasource_id) { + return NULL; + } + + // @todo This will work in most cases, but might fail for multi-valued + // fields. + while (TRUE) { + if (!empty($values->_relationship_objects[$property_path][$this->valueIndex])) { + /** @var \Drupal\Core\TypedData\TypedDataInterface $object */ + $object = $values->_relationship_objects[$property_path][$this->valueIndex]; + $value = $object->getValue(); + if ($value instanceof EntityInterface) { + return $value; + } + } + + if (!$property_path) { + break; + } + list($property_path) = Utility::splitPropertyPath($property_path); + } + + return NULL; + } + + /** + * Gets the value that's supposed to be rendered. + * + * This API exists so that other modules can easily set the values of the + * field without having the need to change the render method as well. + * + * Overridden here to provide an easy way to let this method return arbitrary + * values, without actually touching the $values array. + * + * @param \Drupal\views\ResultRow $values + * An object containing all retrieved values. + * @param string $field + * Optional name of the field where the value is stored. + * + * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::getValue() + */ + public function getValue(ResultRow $values, $field = NULL) { + if (isset($this->overriddenValues[$field])) { + return $this->overriddenValues[$field]; + } + + return parent::getValue($values, $field); + } + + /** + * Runs before any fields are rendered. + * + * This gives the handlers some time to set up before any handler has + * been rendered. + * + * @param \Drupal\views\ResultRow[] $values + * An array of all ResultRow objects returned from the query. + * + * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::preRender() + */ + public function preRender(&$values) { + // We deal with the properties one by one, always loading the necessary + // values for any nested properties coming afterwards. + // @todo This works quite well, but will load each item/entity individually. + // Instead, we should exploit the workflow of proceeding by each property + // on its own to multi-load as much as possible (maybe even entities of + // the same type from different properties). + // @todo Also, this will unnecessarily load items/entities even if all + // required fields are provided in the results. However, to solve this, + // expandRequiredProperties() would have to provide more information, or + // provide a separate properties list for each row. + foreach ($this->expandRequiredProperties() as $datasource_id => $properties) { + foreach ($properties as $property_path => $combined_property_path) { + // Determine the path of the parent property, and the property key to + // take from it for this property. If the name is "_object", we just + // wanted the parent object to be loaded, so we might be done – except + // when the parent is empty, in which case we wanted to load the + // original search result, which we haven't done yet. + list($parent_path, $name) = Utility::splitPropertyPath($property_path); + if ($parent_path && $name == '_object') { + continue; + } + + // Now go through all rows and add the property to them, if necessary. + foreach ($values as $i => $row) { + // Bail for rows with the wrong datasource for this property, or for + // which this field doesn't even apply (which will usually be the + // same, though). + if ($datasource_id != $row->search_api_datasource || !$this->isActiveForRow($row)) { + continue; + } + // Check whether there are parent objects present. If no, either load + // them (in case the parent is the result item itself) or bail. + if (empty($row->_relationship_objects[$parent_path])) { + if ($parent_path) { + continue; + } + else { + $row->_relationship_objects[$parent_path] = array($row->_item->getOriginalObject()); + } + } + + // If the property key is "_object", we just needed to load the search + // result item, so we're now done. + if ($name == '_object') { + continue; + } + + // Determine whether we want to set field values for this property on + // this row. This is the case if the property is one of the explicitly + // retrieved properties and not yet set on the result row object. + $set_values = isset($this->retrievedProperties[$datasource_id][$property_path]) && !isset($row->$combined_property_path); + + if (empty($row->_relationship_objects[$property_path])) { + // Iterate over all parent objects to get their typed data for this + // property and to extract their values. + $row->_relationship_objects[$property_path] = array(); + foreach ($row->_relationship_objects[$parent_path] as $parent) { + // Follow references. + while ($parent instanceof DataReferenceInterface) { + $parent = $parent->getTarget(); + } + // At this point we need the parent to be a complex item, + // otherwise it can't have any children (and thus, our property + // can't be present). + if (!($parent instanceof ComplexDataInterface)) { + continue; + } + // Add the typed data for the property to our relationship objects + // for this property path. To treat list properties correctly + // regarding possible child properties, add all the list items + // individually. + try { + $typed_data = $parent->get($name); + + // If the typed data is an entity, check whether the current + // user can access it. + $value = $typed_data->getValue(); + if ($value instanceof EntityInterface) { + if (!isset($account)) { + $account = $this->getQuery()->getAccessAccount(); + } + if (!$value->access('view', $account)) { + continue; + } + } + + if ($typed_data instanceof ListInterface) { + foreach ($typed_data as $item) { + $row->_relationship_objects[$property_path][] = $item; + } + } + else { + $row->_relationship_objects[$property_path][] = $typed_data; + } + } + catch (\InvalidArgumentException $e) { + // This can easily happen, e.g., when requesting a field that + // only exists on a different bundle. Unfortunately, there is no + // ComplexDataInterface::hasProperty() method, so we can only + // catch and ignore the exception. + } + } + } + + // Initially the array of values, if we want to set them. + if ($set_values) { + $row->$combined_property_path = array(); + } + // Iterate over the typed data objects, extract their values and set + // the relationship objects for the next iteration of the outer loop + // over properties. + foreach ($row->_relationship_objects[$property_path] as $typed_data) { + if ($set_values) { + $row->{$combined_property_path}[] = Utility::extractFieldValues($typed_data); + } + } + // If we just set any field values on the result row, clean them up + // by merging them together (currently it's an array of arrays, but it + // should be just a flat array). + if ($set_values && $row->$combined_property_path) { + $row->$combined_property_path = call_user_func_array('array_merge', $row->$combined_property_path); + } + } + } + } + } + + /** + * Expands the properties to retrieve for this field. + * + * The properties are taken from this object's $retrievedProperties property, + * with all their ancestors also added to the array, with the ancestor + * properties always ordered before their descendants. + * + * This will ensure, when dealing with these properties sequentially, that + * the parent object necessary to load the "child" property is always already + * loaded. + * + * @return string[][] + * The combined property paths to retrieve, keyed by their datasource ID and + * property path. + */ + protected function expandRequiredProperties() { + $required_properties = array(); + foreach ($this->retrievedProperties as $datasource_id => $properties) { + foreach (array_keys($properties) as $property_path) { + $path_to_add = ''; + foreach (explode(':', $property_path) as $component) { + $path_to_add .= ($path_to_add ? ':' : '') . $component; + if (!isset($required_properties[$path_to_add])) { + $required_properties[$datasource_id][$path_to_add] = Utility::createCombinedId($datasource_id, $path_to_add); + } + } + } + } + return $required_properties; + } + + /** + * 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. + */ + public 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']]; + $path = $relationship->realField . ':' . $path; + } + $this->combinedPropertyPath = $path; + // Set the field alias to the combined property path so that Views' code + // can find the raw values, if necessary. + $this->field_alias = $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. + */ + public function getDatasourceId() { + if (!isset($this->datasourceId)) { + list($this->datasourceId) = Utility::splitCombinedId($this->getCombinedPropertyPath()); + } + return $this->datasourceId; + } + + /** + * Renders a single item of a row. + * + * @param int $count + * The index of the item inside the row. + * @param mixed $item + * The item for the field to render. + * + * @return string + * The rendered output. + * + * @see \Drupal\views\Plugin\views\field\MultiItemsFieldHandlerInterface::render_item() + */ + public function render_item($count, $item) { + $this->overriddenValues[NULL] = $item['value']; + $render = $this->render(new ResultRow()); + $this->overriddenValues = array(); + return $render; + } + + /** + * Gets an array of items for the field. + * + * Items should be associative arrays with, if possible, "value" as the actual + * displayable value of the item, plus any items that might be found in the + * "alter" options array for creating links, etc., such as "path", "fragment", + * "query", etc. Additionally, items that might be turned into tokens should + * also be in this array. + * + * @param \Drupal\views\ResultRow $values + * The result row object containing the values. + * + * @return array[] + * An array of items for the field, with each item being an array itself. + * + * @see \Drupal\views\Plugin\views\field\PrerenderList::getItems() + */ + 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. See #2648012 for documenting this. + $items = array(); + foreach ((array) $values->{$property_path} as $i => $value) { + $item = array( + 'value' => $value, + ); + + if ($this->options['link_to_item']) { + $item['make_link'] = TRUE; + $item['url'] = $this->getItemUrl($values, $i); + } + + $items[] = $item; + } + return $items; + } + return array(); + } + + /** + * Renders all items in this field together. + * + * @param array $items + * The items provided by getItems() for a single row. + * + * @return string + * The rendered items. + * + * @see \Drupal\views\Plugin\views\field\PrerenderList::renderItems() + */ + public function renderItems($items) { + if (!empty($items)) { + if ($this->options['multi_type'] == 'separator') { + $render = array( + '#type' => 'inline_template', + '#template' => '{{ items|safe_join(separator) }}', + '#context' => array( + 'items' => $items, + 'separator' => $this->sanitizeValue($this->options['multi_separator'], 'xss_admin'), + ), + ); + } + else { + $render = array( + '#theme' => 'item_list', + '#items' => $items, + '#title' => NULL, + '#list_type' => $this->options['multi_type'], + ); + } + return $this->getRenderer()->render($render); + } + return ''; + } + + /** + * Retrieves an alter options array for linking the given value to its item. + * + * @param \Drupal\views\ResultRow $row + * The Views result row object. + * @param int $i + * The index in this field's values for which the item link should be + * retrieved. + * + * @return \Drupal\Core\Url|null + * The URL for the specified item, or NULL if it couldn't be found. + */ + protected function getItemUrl(ResultRow $row, $i) { + $this->valueIndex = $i; + if ($entity = $this->getEntity($row)) { + return $entity->toUrl('canonical'); + } + + if (!empty($row->_relationship_objects[NULL][0])) { + return $this->getIndex() + ->getDatasource($row->search_api_datasource) + ->getItemUrl($row->_relationship_objects[NULL][0]); + } + + return NULL; + } + +} diff --git a/src/Plugin/views/field/SearchApiNumeric.php b/src/Plugin/views/field/SearchApiNumeric.php new file mode 100644 index 0000000..cd413be --- /dev/null +++ b/src/Plugin/views/field/SearchApiNumeric.php @@ -0,0 +1,54 @@ +traitDefineOptions(); + + $options['format_plural_values'] = array('default' => array()); + + return $options; + } + +} diff --git a/src/Plugin/views/field/SearchApiStandard.php b/src/Plugin/views/field/SearchApiStandard.php new file mode 100644 index 0000000..45d4966 --- /dev/null +++ b/src/Plugin/views/field/SearchApiStandard.php @@ -0,0 +1,32 @@ +definition['filter_type']) ? $this->definition['filter_type'] : 'plain'; + return $this->sanitizeValue($item['value'], $type); + } + +} 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'); @@ -165,25 +166,58 @@ 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() { return parent::defineOptions() + array( - 'search_api_bypass_access' => array( + 'bypass_access' => array( 'default' => FALSE, ), - 'entity_access' => array( + 'skip_access' => array( 'default' => FALSE, ), 'parse_mode' => array( @@ -198,20 +232,22 @@ class SearchApiQuery extends QueryPluginBase { public function buildOptionsForm(&$form, FormStateInterface $form_state) { parent::buildOptionsForm($form, $form_state); - $form['search_api_bypass_access'] = array( + $form['bypass_access'] = array( '#type' => 'checkbox', '#title' => $this->t('Bypass access checks'), - '#description' => $this->t('If the underlying search index has access checks enabled, this option allows you to disable them for this view.'), - '#default_value' => $this->options['search_api_bypass_access'], + '#description' => $this->t('If the underlying search index has access checks enabled (e.g., through the "Content access" processor), this option allows you to disable them for this view. This will never disable any filters placed on this view.'), + '#default_value' => $this->options['bypass_access'], ); if ($this->getEntityTypes(TRUE)) { - $form['entity_access'] = array( + $form['skip_access'] = array( '#type' => 'checkbox', - '#title' => $this->t('Additional access checks on result entities'), - '#description' => $this->t("Execute an access check for all result entities. This prevents users from seeing inappropriate content when the index contains stale data, or doesn't provide access checks. However, result counts, paging and other things won't work correctly if results are eliminated in this way, so only use this as a last resort (and in addition to other checks, if possible)."), - '#default_value' => $this->options['entity_access'], + '#title' => $this->t('Skip entity access checks'), + '#description' => $this->t("By default, an additional access check will be executed for each entity returned by the search query. However, since removing results this way will break paging and result counts, it is preferable to configure the view in a way that it will only return accessible results. If you are sure that only accessible results will be returned in the search, or if you want to show results to which the user normally wouldn't have access, you can enable this option to skip those additional access checks. This should be used with care."), + '#default_value' => $this->options['skip_access'], + '#weight' => -1, ); + $form['bypass_access']['#states']['visible'][':input[name="query[options][skip_access]"]']['checked'] = TRUE; } // @todo Move this setting to the argument and filter plugins where it makes @@ -321,7 +357,7 @@ class SearchApiQuery extends QueryPluginBase { } // Add the "search_api_bypass_access" option to the query, if desired. - if (!empty($this->options['search_api_bypass_access'])) { + if (!empty($this->options['bypass_access'])) { $this->query->setOption('search_api_bypass_access', TRUE); } @@ -444,89 +480,67 @@ 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, unless disabled, check access for all entities in the results. + if (!$this->options['skip_access'] && $this->getEntityTypes(TRUE)) { + $account = $this->getAccessAccount(); + foreach ($results as $item_id => $result) { + $entity_type_id = $result->getDatasource()->getEntityTypeId(); + if (!$entity_type_id) { + continue; + } + $entity = $result->getOriginalObject()->getValue(); + if ($entity instanceof EntityInterface) { + if (!$entity->access('view', $account)) { + unset($results[$item_id]); + } } } } - // 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; + $object = $result->getOriginalObject(FALSE); + if ($object) { + $values['_object'] = $object; + $values['_relationship_objects'][NULL] = array($object); + } $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(); + $values[$field->getCombinedPropertyPath()] = $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; - } - } - - // 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); - } + $values['index'] = $count++; - // 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; - } + $view->result[] = new ResultRow($values); } + } - 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(); - } + /** + * Retrieves the account object to use for access checks for this query. + * + * @return \Drupal\Core\Session\AccountInterface|null + * The account for which to check access to returned or displayed entities. + * Or NULL to use the currently logged-in user. + */ + public function getAccessAccount() { + $account = $this->getOption('search_api_access_account'); + if ($account && is_scalar($account)) { + $account = User::load($account); } - - // 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; - }); + return $account; } /** @@ -1122,6 +1136,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..a37e4da --- /dev/null +++ b/src/Plugin/views/relationship/SearchApiRelationship.php @@ -0,0 +1,54 @@ +alias = $this->field; + } + + /** + * {@inheritdoc} + */ + public function calculateDependencies() { + $dependencies = array(); + + if (!empty($this->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 0b3ae45..3299a0a 100644 --- a/src/Plugin/views/row/SearchApiRow.php +++ b/src/Plugin/views/row/SearchApiRow.php @@ -25,7 +25,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"), * ) */ @@ -121,7 +121,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) { @@ -186,23 +185,22 @@ class SearchApiRow extends RowPluginBase { /** * {@inheritdoc} */ - public function summaryTitle() { - $summary = array(); - foreach ($this->options['view_modes'] as $datasource_id => $bundles) { - $datasource = $this->index->getDatasource($datasource_id); - $bundles_info = $datasource->getBundles(); - foreach ($bundles as $bundle => $view_mode) { - $view_modes = $datasource->getViewModes($bundle); - $label = isset($view_modes[$view_mode]) ? $view_modes[$view_mode] : $this->t('Hidden'); - $args = array( - '@bundle' => $bundles_info[$bundle], - '@datasource' => $datasource->label(), - '@view_mode' => $label, - ); - $summary[] = $this->t('@datasource/@bundle: @view_mode', $args); + 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]); } } - return $summary ? implode('; ', $summary) : $this->t('No settings'); } /** @@ -211,7 +209,7 @@ class SearchApiRow extends RowPluginBase { 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(), @@ -231,13 +229,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); @@ -248,10 +246,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/Processor/FieldsProcessorPluginBase.php b/src/Processor/FieldsProcessorPluginBase.php index 340a4ab..0e23fdd 100644 --- a/src/Processor/FieldsProcessorPluginBase.php +++ b/src/Processor/FieldsProcessorPluginBase.php @@ -227,7 +227,7 @@ abstract class FieldsProcessorPluginBase extends ProcessorPluginBase { foreach ($conditions as $key => &$condition) { if ($condition instanceof ConditionInterface) { $field = $condition->getField(); - if ($this->testField($field, $fields[$field])) { + if (isset($fields[$field]) && $this->testField($field, $fields[$field])) { // We want to allow processors also to easily remove complete // conditions. However, we can't use empty() or the like, as that // would sort out filters for 0 or NULL. So we specifically check only diff --git a/src/Tests/CacheabilityTest.php b/src/Tests/CacheabilityTest.php index 58cf483..ce272d8 100644 --- a/src/Tests/CacheabilityTest.php +++ b/src/Tests/CacheabilityTest.php @@ -61,7 +61,7 @@ class CacheabilityTest extends WebTestBase { $this->drupalLogin($this->adminUser); // Verify that the search results are marked as uncacheable. - $this->drupalGet('search-api-test-fulltext'); + $this->drupalGet('search-api-test'); $this->assertResponse(200); $this->assertHeader('x-drupal-dynamic-cache', 'UNCACHEABLE'); $this->assertTrue(strpos($this->drupalGetHeader('cache-control'), 'no-cache')); diff --git a/src/Tests/HooksTest.php b/src/Tests/HooksTest.php index 348549c..b1504e9 100644 --- a/src/Tests/HooksTest.php +++ b/src/Tests/HooksTest.php @@ -86,7 +86,7 @@ class HooksTest extends WebTestBase { $this->drupalGet($this->getIndexPath('fields/add'), $url_options); $this->assertNoText('timestamp'); - $this->drupalGet('search-api-test-fulltext'); + $this->drupalGet('search-api-test'); // hook_search_api_query_alter was triggered. $this->assertText('Funky blue note'); // hook_search_api_results_alter was triggered. diff --git a/src/Tests/ViewsTest.php b/src/Tests/ViewsTest.php index bf51263..082dbaa 100644 --- a/src/Tests/ViewsTest.php +++ b/src/Tests/ViewsTest.php @@ -9,6 +9,7 @@ namespace Drupal\search_api\Tests; use Drupal\Component\Utility\Html; use Drupal\search_api\Entity\Index; +use Drupal\search_api\Utility; /** * Tests the Views integration of the Search API. @@ -24,7 +25,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 +183,146 @@ class ViewsTest extends WebTestBase { } } + /** + * Test Views admin UI and field handlers. + */ + public function testViewsAdmin() { + $admin_user = $this->drupalCreateUser(array( + 'administer search_api', + 'access administration pages', + 'administer views', + )); + $this->drupalLogin($admin_user); + + $this->drupalGet('admin/structure/views/view/search_api_test_view'); + $this->assertResponse(200); + + // Switch to "Fields" row style. + $this->clickLink($this->t('Rendered entity')); + $this->assertResponse(200); + $edit = array( + 'row[type]' => 'fields', + ); + $this->drupalPostForm(NULL, $edit, $this->t('Apply')); + $this->assertResponse(200); + $this->drupalPostForm(NULL, array(), $this->t('Apply')); + $this->assertResponse(200); + + // Add new fields. First check that the listing seems correct. + $this->clickLink($this->t('Add fields')); + $this->assertResponse(200); + $this->assertText('Test entity datasource'); + $this->assertText('Authored on'); + $this->assertText('Body (indexed field)'); + $this->assertText('Index Test index'); + $this->assertText('Entity ID'); + $this->assertText('Excerpt'); + $this->assertText('The search result excerpted to show found search terms'); + $this->assertText('Relevance'); + $this->assertText('The relevance of this search result with respect to the query'); + $this->assertText('Language code'); + $this->assertText('The language code of the test entity.'); + + // Then add some fields. + $fields = array( + 'views.counter', + 'search_api_datasource_database_search_index_entity_entity_test.id', + 'search_api_index_database_search_index.search_api_datasource', + 'search_api_datasource_database_search_index_entity_entity_test.body', + 'search_api_index_database_search_index.category', + 'search_api_index_database_search_index.keywords', + ); + $edit = array(); + foreach ($fields as $field) { + $edit["name[$field]"] = $field; + } + $this->drupalPostForm(NULL, $edit, $this->t('Add and configure fields')); + $this->assertResponse(200); + + for ($i = 0; $i < 6; ++$i) { + $this->submitFieldsForm(); + } + + // Save the view. + $this->drupalPostForm(NULL, array(), $this->t('Save')); + $this->assertResponse(200); + + // Check the results. + $this->drupalGet('search-api-test'); + $this->assertResponse(200); + + foreach ($this->entities as $id => $entity) { + $fields = array( + 'search_api_datasource', + 'id', + 'body', + 'category', + 'keywords', + ); + foreach ($fields as $field) { + if ($field != 'search_api_datasource') { + $data = Utility::extractFieldValues($entity->get($field)); + if (!$data) { + $data = array('[EMPTY]'); + } + } + else { + $data = array('entity:entity_test'); + } + $prefix = "#$id [$field] "; + $text = $prefix . implode("|$prefix", $data); + $this->assertText($text, "Correct value displayed for field $field on entity #$id (\"$text\")"); + } + } + } + + /** + * Submits the field handler config form currently displayed. + */ + protected function submitFieldsForm() { + $url_parts = explode('/', $this->getUrl()); + $field = array_pop($url_parts); + + $edit['options[fallback_options][multi_separator]'] = '|'; + $edit['options[alter][alter_text]'] = TRUE; + $edit['options[alter][text]'] = "#{{counter}} [$field] {{ $field }}"; + $edit['options[empty]'] = "#{{counter}} [$field] [EMPTY]"; + + switch ($field) { + case 'counter': + $edit = array( + 'options[exclude]' => TRUE, + ); + break; + + case 'id': + $edit['options[field_rendering]'] = FALSE; + break; + + case 'search_api_datasource': + unset($edit['options[fallback_options][multi_separator]']); + break; + + case 'body': + break; + + case 'category': + break; + + case 'keywords': + $edit['options[field_rendering]'] = FALSE; + break; + + } + + $button_label = $this->t('Apply'); + $buttons = $this->xpath('//input[starts-with(@value, :label)]', array(':label' => $button_label)); + if ($buttons) { + $button_label = $buttons[0]['value']; + } + + $this->drupalPostForm(NULL, $edit, $button_label); + $this->assertResponse(200); + } + } diff --git a/src/Utility.php b/src/Utility.php index 3674331..8e8ccda 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. @@ -243,33 +244,53 @@ class Utility { * The field into which to put the extracted data. */ public static function extractField(TypedDataInterface $data, FieldInterface $field) { + $values = static::extractFieldValues($data); + + // If the data type of the field is a custom one, then the value can be + // altered by the data type plugin. + $data_type_manager = \Drupal::service('plugin.manager.search_api.data_type'); + if ($data_type_manager->hasDefinition($field->getType())) { + /** @var \Drupal\search_api\DataType\DataTypeInterface $data_type_plugin */ + $data_type_plugin = $data_type_manager->createInstance($field->getType()); + foreach ($values as $i => $value) { + $values[$i] = $data_type_plugin->getValue($value); + } + } + + $field->setValues($values); + $field->setOriginalType($data->getDataDefinition()->getDataType()); + } + + /** + * Extracts field values from a typed data object. + * + * @param \Drupal\Core\TypedData\TypedDataInterface $data + * The typed data object. + * + * @return array + * An array of values. + */ + public static function extractFieldValues(TypedDataInterface $data) { if ($data->getDataDefinition()->isList()) { + $values = array(); foreach ($data as $piece) { - self::extractField($piece, $field); + $values[] = self::extractFieldValues($piece); } - return; + return $values ? call_user_func_array('array_merge', $values) : array(); } + $value = $data->getValue(); $definition = $data->getDataDefinition(); if ($definition instanceof ComplexDataDefinitionInterface) { $property = $definition->getMainPropertyName(); if (isset($value[$property])) { - $value = $value[$property]; + return array($value[$property]); } } elseif (is_array($value)) { - $value = reset($value); - } - - // If the data type of the field is a custom one, then the value can be - // altered by the data type plugin. - $data_type_manager = \Drupal::service('plugin.manager.search_api.data_type'); - if ($data_type_manager->hasDefinition($field->getType())) { - $value = $data_type_manager->createInstance($field->getType())->getValue($value); + return array_values($value); } - - $field->addValue($value); - $field->setOriginalType($definition->getDataType()); + return array($value); } /** @@ -537,11 +558,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_view.yml b/tests/search_api_test_views/config/install/views.view.search_api_test_view.yml index cc8337e..47400ce 100644 --- a/tests/search_api_test_views/config/install/views.view.search_api_test_view.yml +++ b/tests/search_api_test_views/config/install/views.view.search_api_test_view.yml @@ -19,8 +19,8 @@ display: query: type: search_api_query options: - search_api_bypass_access: false - entity_access: false + bypass_access: false + skip_access: true parse_mode: terms exposed_form: type: basic @@ -59,11 +59,9 @@ display: type: search_api options: view_modes: - bundle: - 'article': default - 'page': default - datasource: - 'entity:entity_test': default + 'entity:entity_test': + article: default + page: default filters: search_api_fulltext: id: search_api_fulltext @@ -78,7 +76,7 @@ display: exposed: true expose: operator_id: search_api_fulltext_op - label: '' + label: 'Fulltext search' description: '' use_operator: true operator: search_api_fulltext_op @@ -155,7 +153,19 @@ display: anonymous: '0' administrator: '0' is_grouped: false - sorts: { } + sorts: + search_api_id: + id: search_api_id + table: search_api_index_database_search_index + field: search_api_id + relationship: none + group_type: group + admin_label: '' + order: ASC + exposed: false + expose: + label: '' + plugin_id: search_api title: 'Fulltext test index' header: result: 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 deleted file mode 100644 index 0031593..0000000 --- a/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml +++ /dev/null @@ -1,198 +0,0 @@ -base_field: search_api_id -base_table: search_api_index_database_search_index -core: 8.x -description: '' -status: true -display: - default: - display_plugin: default - id: default - display_title: Master - position: 0 - display_options: - access: - type: none - options: { } - cache: - type: none - options: { } - query: - type: search_api_query - options: - search_api_bypass_access: false - entity_access: false - parse_mode: terms - exposed_form: - type: basic - options: - submit_button: Search - reset_button: false - reset_button_label: Reset - exposed_sorts_label: 'Sort by' - expose_sort_order: true - sort_asc_label: Asc - sort_desc_label: Desc - pager: - type: full - options: - items_per_page: 10 - offset: 0 - id: 0 - total_pages: null - expose: - items_per_page: false - items_per_page_label: 'Items per page' - items_per_page_options: '5, 10, 20, 40, 60' - items_per_page_options_all: false - items_per_page_options_all_label: '- All -' - offset: false - offset_label: Offset - tags: - previous: '‹ previous' - next: 'next ›' - first: '« first' - last: 'last »' - quantity: 9 - style: - type: default - row: - type: search_api - options: - view_modes: - bundle: - 'article': default - 'page': default - datasource: - 'entity:entity_test': default - fields: - search_api_id: - table: search_api_index_database_search_index - field: search_api_id - id: search_api_id - plugin_id: numeric - relationship: none - group_type: group - admin_label: '' - label: 'Entity ID' - exclude: false - alter: - alter_text: false - text: '' - make_link: false - path: '' - absolute: false - external: false - replace_spaces: false - path_case: none - trim_whitespace: false - alt: '' - rel: '' - link_class: '' - prefix: '' - suffix: '' - target: '' - nl2br: false - max_length: 0 - word_boundary: true - ellipsis: true - more_link: false - more_link_text: '' - more_link_path: '' - strip_tags: false - trim: false - preserve_tags: '' - html: false - element_type: '' - element_class: '' - element_label_type: '' - element_label_class: '' - element_label_colon: true - element_wrapper_type: '' - element_wrapper_class: '' - element_default_classes: true - empty: '' - hide_empty: false - empty_zero: false - hide_alter_empty: true - set_precision: false - precision: 0 - decimal: . - separator: ',' - format_plural: false - format_plural_string: "1\x03@count" - prefix: '' - suffix: '' - filters: - search_api_fulltext: - id: search_api_fulltext - table: search_api_index_database_search_index - field: search_api_fulltext - relationship: none - group_type: group - admin_label: '' - operator: and - value: '' - group: 1 - exposed: true - expose: - operator_id: search_api_fulltext_op - label: 'Fulltext search' - description: '' - use_operator: false - operator: search_api_fulltext_op - identifier: search_api_fulltext - required: false - remember: false - multiple: false - remember_roles: - authenticated: authenticated - anonymous: '0' - administrator: '0' - is_grouped: false - group_info: - label: '' - description: '' - identifier: '' - optional: true - widget: select - multiple: false - remember: false - default_group: All - default_group_multiple: { } - group_items: { } - min_length: 0 - fields: { } - plugin_id: search_api_fulltext - sorts: { } - title: 'Fulltext test index' - header: - result: - id: result - table: views - field: result - relationship: none - group_type: group - admin_label: '' - empty: false - content: 'Displaying @total search results' - plugin_id: result - footer: { } - empty: { } - relationships: { } - arguments: { } - page_1: - display_plugin: page - id: page_1 - display_title: Page - position: 1 - display_options: - path: search-api-test-fulltext -label: 'Search API Test Fulltext search view' -module: views -id: search_api_test_views_fulltext -tag: '' -langcode: en -dependencies: - module: - - search_api - - search_api_test_views