diff --git a/config/schema/search_api.views.schema.yml b/config/schema/search_api.views.schema.yml index d5c8bb6..7be181f 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 items and all displayed 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. diff --git a/search_api.views.inc b/search_api.views.inc index 0760ea8..ad1c8fd 100644 --- a/search_api.views.inc +++ b/search_api.views.inc @@ -70,7 +70,7 @@ function search_api_views_data() { '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(); @@ -79,7 +79,7 @@ function search_api_views_data() { $field_definition['real field'] = $field_id; } if (isset($field_definition['field'])) { - $field_definition['field']['title'] = t('@field (indexed)', array('@field' => $field_label)); + $field_definition['field']['title'] = t('@field (indexed field)', array('@field' => $field_label)); } $table[$field_alias] = $field_definition; } @@ -420,7 +420,7 @@ function _search_api_views_datasource_table(DatasourceInterface $datasource, arr $datasource_id = $datasource->getPluginId(); $table = array( 'table' => array( - 'group' => $datasource->label(), + 'group' => t('@datasource datasource', array('@datasource' => $datasource->label())), 'index' => $datasource->getIndex()->id(), 'datasource' => $datasource_id, ), @@ -474,7 +474,7 @@ function _search_api_views_entity_type_table($entity_type_id, array &$data) { $table = array( 'table' => array( - 'group' => $entity_type->getLabel(), + 'group' => t('@entity_type relationship', array('@entity_type' => $entity_type->getLabel())), 'entity type' => $entity_type_id, 'entity revision' => FALSE, ), @@ -593,6 +593,50 @@ function _search_api_views_add_handlers_for_properties(array $properties, array * @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)) { @@ -603,32 +647,27 @@ function _search_api_views_get_field_handler_for_property(DataDefinitionInterfac 'id' => 'search_api', ); -// $mapping['field_item:text_long'] = -// $mapping['field_item:text_with_summary'] = array( -// 'id' => 'search_api', -// 'html' => TRUE, -// ); -// -// $mapping['field_item:string_long'] = -// $mapping['field_item:string'] = -// $mapping['field_item:path'] = -// $mapping['email'] = -// $mapping['uri'] = -// $mapping['filter_format'] = -// $mapping['duration_iso8601'] = array( -// 'id' => 'search_api', -// ); -// -// $mapping['integer'] = -// $mapping['timespan'] = array( -// 'id' => 'search_api_numeric', -// ); -// -// $mapping['decimal'] = -// $mapping['float'] = array( -// 'id' => 'search_api_numeric', -// 'float' => TRUE, -// ); + $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'] = @@ -637,16 +676,34 @@ function _search_api_views_get_field_handler_for_property(DataDefinitionInterfac 'id' => 'search_api_date', ); -// $mapping['boolean'] = -// $mapping['field_item:boolean'] = array( -// 'id' => 'search_api_boolean', -// ); -// -// $mapping['field_item:entity_reference'] = -// $mapping['field_item:comment'] = array( -// 'id' => 'search_api_entity', -// ); + $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 post-process them to correct that. + 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); @@ -685,28 +742,5 @@ function _search_api_views_get_field_handler_for_property(DataDefinitionInterfac uksort($mappings['regex'], $compare); } - // 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; - } - - return $definition; + 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 14154b7..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,7 +32,7 @@ display: query: type: search_api_query options: - search_api_bypass_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 index 4293cc5..4b1032f 100644 --- a/src/Plugin/views/SearchApiHandlerTrait.php +++ b/src/Plugin/views/SearchApiHandlerTrait.php @@ -24,6 +24,24 @@ trait SearchApiHandlerTrait { public function ensureMyTable() {} /** + * Determines the entity type used by this handler. + * + * If this handler uses a relationship, the base class of the relationship is + * taken into account. + * + * @return string + * The machine name of the entity type. + * + * @see \Drupal\views\Plugin\views\HandlerBase::getEntityType() + */ + public function getEntityType() { + if (isset($this->definition['entity_type'])) { + return $this->definition['entity_type']; + } + return parent::getEntityType(); + } + + /** * Returns the active search index. * * @return \Drupal\search_api\IndexInterface|null diff --git a/src/Plugin/views/field/SearchApiBoolean.php b/src/Plugin/views/field/SearchApiBoolean.php new file mode 100644 index 0000000..8a2dbe6 --- /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..2cc2c0c --- /dev/null +++ b/src/Plugin/views/field/SearchApiEntityField.php @@ -0,0 +1,291 @@ +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_options'] = array('default' => $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'; + } + } + // For some strange reason, the boolean formatter hard-codes the field name + // to "field_boolean", breaking our parent class's rewriteStatesSelector() + // call for fixing "#states". Therefore, we just apply that here again, with + // the hard-coded field name. + if (!empty($form['settings'])) { + FormHelper::rewriteStatesSelector($form['settings'], "fields[field_boolean][settings_edit_form]", 'options'); + } + + + + // Same for the options form from the fallback handler, but here we only + // add the options not already present, and put the reverse "#states" + // directive on them. + $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); + + 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/SearchApiFieldTrait.php b/src/Plugin/views/field/SearchApiFieldTrait.php index 7a5aac0..6155008 100644 --- a/src/Plugin/views/field/SearchApiFieldTrait.php +++ b/src/Plugin/views/field/SearchApiFieldTrait.php @@ -15,6 +15,7 @@ use Drupal\Core\TypedData\DataReferenceInterface; use Drupal\Core\TypedData\ListInterface; use Drupal\search_api\Plugin\views\SearchApiHandlerTrait; use Drupal\search_api\Utility; +use Drupal\views\FieldAPIHandlerTrait; use Drupal\views\Plugin\views\field\MultiItemsFieldHandlerInterface; use Drupal\views\ResultRow; @@ -26,7 +27,12 @@ use Drupal\views\ResultRow; */ trait SearchApiFieldTrait { - use SearchApiHandlerTrait, StringTranslationTrait; + use SearchApiHandlerTrait; + use FieldAPIHandlerTrait { + getFieldDefinition as traitGetFieldDefinition; + getFieldStorageDefinition as traitGetFieldStorageDefinition; + } + use StringTranslationTrait; /** * Contains the properties needed by this field handler. @@ -55,6 +61,9 @@ trait SearchApiFieldTrait { /** * Contains overridden values to be returned on the next getValue() call. * + * The values are keyed by the field given as $field in the call, so that it's + * possible to return different values based on the field. + * * @var array * * @see SearchApiFieldTrait::getValue() @@ -62,6 +71,28 @@ trait SearchApiFieldTrait { protected $overriddenValues = array(); /** + * Index in the current row's field values that is currently displayed. + * + * @var int + * + * @see SearchApiFieldTrait::getEntity() + */ + protected $valueIndex = 0; + + /** + * Determines whether this field can have multiple values. + * + * When this can't be reliably determined, the method defaults to TRUE. + * + * @return bool + * TRUE if this field can have multiple values (or if it couldn't be + * determined); FALSE otherwise. + */ + public function isMultiple() { + return $this instanceof MultiItemsFieldHandlerInterface; + } + + /** * Information about options for all kinds of purposes will be held here. * * @code @@ -78,14 +109,14 @@ trait SearchApiFieldTrait { * * @see \Drupal\views\Plugin\views\PluginBase::defineOptions() */ - protected function defineOptions() { + public function defineOptions() { $options = parent::defineOptions(); $options['link_to_item'] = array('default' => FALSE); - if ($this instanceof MultiItemsFieldHandlerInterface) { - $options['type'] = array('default' => 'separator'); - $options['separator'] = array('default' => ', '); + if ($this->isMultiple()) { + $options['multi_type'] = array('default' => 'separator'); + $options['multi_separator'] = array('default' => ', '); } return $options; @@ -111,7 +142,7 @@ trait SearchApiFieldTrait { '#default_value' => $this->options['link_to_item'], ); - if ($this instanceof MultiItemsFieldHandlerInterface) { + if ($this->isMultiple()) { $form['multi_value_settings'] = array( '#type' => 'details', '#title' => $this->t('Multiple values handling'), @@ -119,7 +150,7 @@ trait SearchApiFieldTrait { '#weight' => 80, ); - $form['type'] = array( + $form['multi_type'] = array( '#type' => 'radios', '#title' => $this->t('Display type'), '#options' => array( @@ -127,23 +158,23 @@ trait SearchApiFieldTrait { 'ol' => $this->t('Ordered list'), 'separator' => $this->t('Simple separator'), ), - '#default_value' => $this->options['type'], + '#default_value' => $this->options['multi_type'], '#fieldset' => 'multi_value_settings', + '#weight' => 0, ); - $form['separator'] = array( + $form['multi_separator'] = array( '#type' => 'textfield', '#title' => $this->t('Separator'), - '#default_value' => $this->options['separator'], + '#default_value' => $this->options['multi_separator'], '#states' => array( 'visible' => array( - ':input[name="options[type]"]' => array('value' => 'separator'), + ':input[name="options[multi_type]"]' => array('value' => 'separator'), ), ), '#fieldset' => 'multi_value_settings', + '#weight' => 1, ); } - - // @todo Field API field handling. } /** @@ -193,6 +224,45 @@ trait SearchApiFieldTrait { } /** + * 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 @@ -228,6 +298,8 @@ trait SearchApiFieldTrait { * @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 @@ -236,41 +308,28 @@ trait SearchApiFieldTrait { // 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. - $required_properties = $this->expandRequiredProperties(); - /** @var \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $data_definitions */ - $data_definitions = array(); - - foreach ($required_properties as $datasource_id => $properties) { + 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 ($name == '_object') { + if ($parent_path && $name == '_object') { continue; } - $combined_parent_path = Utility::createCombinedId($datasource_id, $parent_path); - $to_load = array(); - -// if ($parent_path) { -// if (empty($data_definitions[$parent_path])) { -// continue; -// } -// $parent_definitions = $data_definitions[$parent_path]->getPropertyDefinitions(); -// } -// else { -// $parent_definitions = $this->getIndex()->getPropertyDefinitions($datasource_id, FALSE); -// } -// if (!isset($parent_definitions[$name])) { -// continue; -// } -// $definition = Utility::getInnerProperty($parent_definitions[$name]); -// $data_definitions[$property_path] = $definition; + // 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; } - if (isset($row->$combined_property_path)) { - 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; @@ -280,28 +339,85 @@ trait SearchApiFieldTrait { } } - $row->$combined_property_path = array(); - foreach ($row->_relationship_objects[$parent_path] as $parent) { - while ($parent instanceof DataReferenceInterface) { - $parent = $parent->getTarget(); - } - if (!($parent instanceof ComplexDataInterface)) { - continue; - } - $typed_data = $parent->get($name); - if (isset($this->retrievedProperties[$datasource_id][$property_path])) { - $row->$combined_property_path[] = Utility::extractFieldValues($typed_data); - } - if ($typed_data instanceof ListInterface) { - foreach ($typed_data as $item) { - $row->_relationship_objects[$property_path][] = $item; + // 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. } } - else { - $row->_relationship_objects[$property_path][] = $typed_data; + } + + // 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 ($row->$combined_property_path) { + // 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); } } @@ -363,7 +479,7 @@ trait SearchApiFieldTrait { * @return string * The combined property path. */ - protected function getCombinedPropertyPath() { + public function getCombinedPropertyPath() { if (!isset($this->combinedPropertyPath)) { // Add the property path of any relationships used to arrive at this // field. @@ -378,6 +494,9 @@ trait SearchApiFieldTrait { $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; } @@ -389,7 +508,7 @@ trait SearchApiFieldTrait { * The datasource ID of this field, or NULL if it doesn't belong to a * specific datasource. */ - protected function getDatasourceId() { + public function getDatasourceId() { if (!isset($this->datasourceId)) { list($this->datasourceId) = Utility::splitCombinedId($this->getCombinedPropertyPath()); } @@ -469,13 +588,13 @@ trait SearchApiFieldTrait { */ public function renderItems($items) { if (!empty($items)) { - if ($this->options['type'] == 'separator') { + 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['separator'], 'xss_admin'), + 'separator' => $this->sanitizeValue($this->options['multi_separator'], 'xss_admin'), ), ); } @@ -484,7 +603,7 @@ trait SearchApiFieldTrait { '#theme' => 'item_list', '#items' => $items, '#title' => NULL, - '#list_type' => $this->options['type'], + '#list_type' => $this->options['multi_type'], ); } return $this->getRenderer()->render($render); @@ -505,24 +624,15 @@ trait SearchApiFieldTrait { * The URL for the specified item, or NULL if it couldn't be found. */ protected function getItemUrl(ResultRow $row, $i) { - list($datasource_id, $property_path) = Utility::splitCombinedId($this->getCombinedPropertyPath()); + $this->valueIndex = $i; + if ($entity = $this->getEntity($row)) { + return $entity->toUrl('canonical'); + } - // @todo This will work in most cases, but might fail when linking a multi- - // valued non-entity field's values to its containing entity (since then - // $i will be off one level up). Rare use case, though, probably. - while (!empty($row->_relationship_objects[$property_path][$i])) { - /** @var \Drupal\Core\TypedData\TypedDataInterface $object */ - $object = $row->_relationship_objects[$property_path][$i]; - if (!$property_path) { - return $this->getIndex() - ->getDatasource($datasource_id) - ->getItemUrl($object); - } - $value = $object->getValue(); - if ($value instanceof EntityInterface) { - return $value->toUrl('canonical'); - } - list($property_path) = Utility::splitPropertyPath($property_path); + 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..d57826c --- /dev/null +++ b/src/Plugin/views/field/SearchApiNumeric.php @@ -0,0 +1,54 @@ +traitDefineOptions(); + + $options['format_plural_string'] = array('default' => array()); + + return $options; + } + +} diff --git a/src/Plugin/views/field/SearchApiField.php b/src/Plugin/views/field/SearchApiStandard.php similarity index 63% rename from src/Plugin/views/field/SearchApiField.php rename to src/Plugin/views/field/SearchApiStandard.php index b3f2af5..d6c5fc7 100644 --- a/src/Plugin/views/field/SearchApiField.php +++ b/src/Plugin/views/field/SearchApiStandard.php @@ -2,12 +2,13 @@ /** * @file - * Contains \Drupal\search_api\Plugin\views\field\SearchApiField. + * Contains \Drupal\search_api\Plugin\views\field\SearchApiStandard. */ namespace Drupal\search_api\Plugin\views\field; -use Drupal\views\Plugin\views\field\PrerenderList; +use Drupal\views\Plugin\views\field\FieldPluginBase; +use Drupal\views\Plugin\views\field\MultiItemsFieldHandlerInterface; /** * Provides a default handler for fields in Search API Views. @@ -16,7 +17,7 @@ use Drupal\views\Plugin\views\field\PrerenderList; * * @ViewsField("search_api") */ -class SearchApiField extends PrerenderList { +class SearchApiStandard extends FieldPluginBase implements MultiItemsFieldHandlerInterface{ use SearchApiFieldTrait; diff --git a/src/Plugin/views/query/SearchApiQuery.php b/src/Plugin/views/query/SearchApiQuery.php index d42cc94..7788602 100644 --- a/src/Plugin/views/query/SearchApiQuery.php +++ b/src/Plugin/views/query/SearchApiQuery.php @@ -10,16 +10,19 @@ namespace Drupal\search_api\Plugin\views\query; use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Utility\Html; use Drupal\Core\Database\Query\ConditionInterface; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; use Drupal\search_api\Entity\Index; use Drupal\search_api\Query\ConditionGroupInterface; use Drupal\search_api\Utility; +use Drupal\user\Entity\User; use Drupal\views\Plugin\views\display\DisplayPluginBase; use Drupal\views\Plugin\views\query\QueryPluginBase; use Drupal\views\ResultRow; use Drupal\views\ViewExecutable; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Defines a Views query class for searching on Search API indexes. @@ -30,9 +33,6 @@ use Drupal\views\ViewExecutable; * help = @Translation("The query will be generated and run using the Search API.") * ) */ -// @todo Would be great to have a common interface with -// \Drupal\search_api\Query\QueryInterface here, but probably not really -// possible due to conflicts. class SearchApiQuery extends QueryPluginBase { /** @@ -211,10 +211,10 @@ class SearchApiQuery extends QueryPluginBase { */ 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( @@ -229,20 +229,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 @@ -352,7 +354,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); } @@ -480,6 +482,23 @@ class SearchApiQuery extends QueryPluginBase { // 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]); + } + } + } + } + foreach ($results as $item_id => $result) { $values = array(); $values['_item'] = $result; @@ -507,6 +526,21 @@ class SearchApiQuery extends QueryPluginBase { } /** + * 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); + } + return $account; + } + + /** * Returns the Search API query object used by this Views query. * * @return \Drupal\search_api\Query\QueryInterface|null diff --git a/src/Plugin/views/relationship/SearchApiRelationship.php b/src/Plugin/views/relationship/SearchApiRelationship.php index df71e6b..a37e4da 100644 --- a/src/Plugin/views/relationship/SearchApiRelationship.php +++ b/src/Plugin/views/relationship/SearchApiRelationship.php @@ -31,7 +31,9 @@ class SearchApiRelationship extends RelationshipPluginBase { /** * {@inheritdoc} */ - public function query() {} + public function query() { + $this->alias = $this->field; + } /** * {@inheritdoc} 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/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 150de86..6fba400 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 diff --git a/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml b/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml index a397f64..ea1e341 100644 --- a/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml +++ b/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml @@ -19,7 +19,7 @@ display: query: type: search_api_query options: - search_api_bypass_access: false + bypass_access: false parse_mode: terms exposed_form: type: basic