diff --git a/config/schema/search_api.views.schema.yml b/config/schema/search_api.views.schema.yml index 91264bc..47a29ff 100644 --- a/config/schema/search_api.views.schema.yml +++ b/config/schema/search_api.views.schema.yml @@ -5,6 +5,17 @@ views.query.search_api_query: search_api_bypass_access: type: boolean label: If the underlying search index has access checks enabled, this option allows you to disable them for this view. + parse_mode: + type: string + label: Chooses how the search keys will be parsed. + +views.query.search_api_entity_query: + type: views_query + label: 'Search API entity query' + mapping: + search_api_bypass_access: + type: boolean + label: If the underlying search index has access checks enabled, this option allows you to disable them for this view. entity_access: type: boolean label: Execute an access check for all result entities. diff --git a/search_api.views.inc b/search_api.views.inc index c242642..dabe135 100644 --- a/search_api.views.inc +++ b/search_api.views.inc @@ -6,70 +6,64 @@ */ use Drupal\search_api\Entity\Index; +use Drupal\search_api\Utility; +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Field\FieldDefinitionInterface; /** * Implements hook_views_data(). */ function search_api_views_data() { $data = array(); - try { - foreach (Index::loadMultiple() as $index) { - // Fill in base data. - $key = 'search_api_index_' . $index->id(); - $table = &$data[$key]; - $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())), - 'query_id' => 'search_api_query', - ); - - // @todo Add field, filter, … handlers for all fields. - - // 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_sort'; - - $table['search_api_datasource']['title'] = t('Datasource'); - $table['search_api_datasource']['help'] = t("The data source ID"); - $table['search_api_datasource']['field']['id'] = 'standard'; - // @todo Enable filtering on datasource. - $table['search_api_datasource']['sort']['id'] = 'search_api_sort'; - - $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_sort'; - - $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). + + foreach (Index::loadMultiple() as $index) { + $key = 'search_api_index_' . $index->id(); + $table = &$data[$key]; + $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())), + 'query_id' => 'search_api_query', + ); + _search_api_views_data_special_fields($table, $index); + _search_api_views_data_searchapi_fields($table, $index); + + $i = 0; + foreach ($index->getDatasources() as $datasource) { + if (!$entity_type_id = $datasource->getEntityTypeId()) { + continue; + } + $entity_manager = \Drupal::entityManager(); + if (!$entity_type = $entity_manager->getDefinition($entity_type_id)) { + continue; + } + $storage = $entity_manager->getStorage($entity_type_id); + if (!$base_table = $entity_type->getBaseTable()) { + continue; + } + $entity_tables = array($base_table => $entity_type_id); + // Some entities may not have a data table. + $data_table = $entity_type->getDataTable(); + if ($data_table) { + $entity_tables[$data_table] = $entity_type_id; + } + // Description of the field tables. + // @todo This is the same code as views itself uses, issue about the sql + // assumptions https://www.drupal.org/node/2079019. + $table_mapping = $storage->getTableMapping(); + + foreach ($entity_tables as $base_table => $entity_type_id) { + $table[$base_table]['relationship'] = array( + 'label' => $base_table, + 'title' => $base_table, + 'id' => 'search_api_datasource_entity', + 'base' => $base_table, + ); + } } } - catch (Exception $e) { - watchdog_exception('search_api', $e); - } return $data; } @@ -81,6 +75,10 @@ function search_api_views_plugins_cache_alter(array &$plugins) { $bases = array(); foreach (Index::loadMultiple() as $index) { $bases[] = 'search_api_index_' . $index->id(); + $i = 0; + foreach ($index->getDatasources() as $datasource) { + $bases[] = 'search_api_index_entity_' . sprintf("%02d", ++$i) . '_' . $index->id(); + } } $plugins['search_api']['base'] = $bases; } @@ -93,6 +91,99 @@ function search_api_views_plugins_row_alter(array &$plugins) { $bases = array(); foreach (Index::loadMultiple() as $index) { $bases[] = 'search_api_index_' . $index->id(); + $i = 0; + foreach ($index->getDatasources() as $datasource) { + $bases[] = 'search_api_index_entity_' . sprintf("%02d", ++$i) . '_' . $index->id(); + } } $plugins['search_api']['base'] = $bases; } + +function _search_api_views_data_special_fields(&$table, $index) { + // Add handlers for special fields. + $table['search_api_id']['title'] = t('Search API ID'); + $table['search_api_id']['help'] = t("The entity's encoded Search API ID"); + $table['search_api_id']['field']['id'] = 'standard'; + $table['search_api_id']['sort']['id'] = 'search_api_sort'; + + $table['search_api_datasource']['title'] = t('Datasource'); + $table['search_api_datasource']['help'] = t("The data source ID"); + $table['search_api_datasource']['field']['id'] = 'standard'; + // @todo Enable filtering on datasource. + // $table['search_api_datasource']['sort']['id'] = 'search_api_sort'; + + $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_sort'; + + $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'; +} + + +function _search_api_views_data_searchapi_fields(&$table, $index) { + foreach ($index->getFields() as $field_id => $field) { + // Fields specifically + // + // $field->propertyPath could be run through to make sure + // key does not already exist if it can possibly be duplicated. + // + $id = $field->getType(); + switch ($id) { + case 'integer': + $type = 'integer'; + $id = 'numeric'; + break; + case 'string': + $type = ''; + $id = 'standard'; + break; + case 'text': + $type = ''; + $id = 'markup'; + // $format REQUIRED + break; + case 'date': + $type = ''; + $id = 'date'; + break; + } + + // @todo + // We need something to flag up if a field is going to be + // single or multiple. + if ($field->getPropertyPath() == 'field_tags:entity:name') { + $id = 'standard_list'; + } + // ENDS + + $table['search_api_item_' . $field->getPropertyPath()] = array( + 'title' => $field->getLabel(), + 'help' => $field->getDescription(), + 'field' => array( + 'id' => $id, + 'real field' => $field_id, + ), + ); + if ($type) { + $table['search_api_item_' . $field->getPropertyPath()]['type'] = $type; + } + } +} diff --git a/src/Plugin/views/field/StandardList.php b/src/Plugin/views/field/StandardList.php new file mode 100644 index 0000000..748a1ea --- /dev/null +++ b/src/Plugin/views/field/StandardList.php @@ -0,0 +1,27 @@ +getStorage('search_api_index')->load($index_id); + } + return Index::load($index_id); + } + return NULL; + } + + + protected function addResults(array $results, ViewExecutable $view) { + $rows = $this->retrieveItemResults($results); + $this->loadResultEntities($rows); + $view->result = array_values($rows); + } + + protected function loadResultEntities(&$results) { + foreach ($results as $item_id => $result) { + try { + $entity = $this->index->loadItem($result->search_api_id)->getValue(); + if (!empty($this->options['entity_access'])) { + if (!$entity->access('view')) { + continue; + } + } + $results[$item_id]->_entity = $entity; + } + catch (Exception $e) { + // No entity so no wrapper to getValue(). + } + } + } +} + + diff --git a/src/Plugin/views/query/SearchApiQuery.php b/src/Plugin/views/query/SearchApiQuery.php index 9165c1f..fce2471 100644 --- a/src/Plugin/views/query/SearchApiQuery.php +++ b/src/Plugin/views/query/SearchApiQuery.php @@ -18,6 +18,7 @@ use Drupal\views\Plugin\views\display\DisplayPluginBase; use Drupal\views\Plugin\views\query\QueryPluginBase; use Drupal\views\ResultRow; use Drupal\views\ViewExecutable; +use Drupal\search_api\Utility; /** * Defines a Views query class for searching on Search API indexes. @@ -135,7 +136,7 @@ class SearchApiQuery extends QueryPluginBase { try { parent::init($view, $display, $options); $this->fields = 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()))); } @@ -156,9 +157,14 @@ class SearchApiQuery extends QueryPluginBase { * * @return $this */ - public function addField($field) { + // @todo This is _just_ a function added by \Drupal\views\Plugin\views\query\Sql ? + // It's assumed by fields. Most important is that it indexes. + // Is is used elsewhere? + public function addField($table, $field, $alias = '', $params = array()) { + // Is there some reason to be called with an alias, would then + // need to store it? $this->fields[$field] = TRUE; - return $this; + return $alias ? $alias : $field; } /** @@ -382,6 +388,7 @@ class SearchApiQuery extends QueryPluginBase { if ($results->getResultItems()) { $this->addResults($results->getResultItems(), $view); } + $view->execute_time = microtime(TRUE) - $start; // Trigger pager postExecute(). @@ -431,23 +438,113 @@ 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')) { + $rows = $this->retrieveItemResults($results); + $entity_information = $this->getEntityTableInfo(); + if (!empty($entity_information)) { + $this->loadResultEntities($rows, $view, $entity_information); + } + $view->result = array_values($rows); + array_walk($view->result, function (ResultRow $row, $index) { + $row->index = $index; + }); + } + + /** + * Add relationship. + * + * @todo This is a method called by standard relationship handlers. + * So it's called when a chained entity relationship is added. + * This is probably good enough to add data to load the entity + * when executed. + */ + public function addRelationship($alias, JoinPluginBase $join, $base, $link_point = NULL) { + // @todo What does this do? + if (empty($link_point)) { + $link_point = $this->view->storage->get('base_table'); + } + elseif (!array_key_exists($link_point, $this->relationships)) { + return FALSE; + } + + // Make sure $alias isn't already used; if it, start adding stuff. + $alias_base = $alias; + $count = 1; + while (!empty($this->relationships[$alias])) { + $alias = $alias_base . '_' . $count++; + } + + $this->relationships[$alias] = array( + 'link' => $link_point, + 'table' => $join->table, + 'base' => $base, + ); + + return $alias; + } + + /** + * Adds a query tag. + * + * @todo This is a method called by standard relationship handlers. + */ + public function addTag($tag) { + $this->tags[] = $tag; + } + + protected function loadResultEntities(&$results, ViewExecutable $view, $entity_information) { + // List entities types to load from the relationships on the view. + $entities_to_load = []; + foreach ($entity_information as $join) { + $entities_to_load[$join['entity_type']][] = $join; + } + + foreach ($results as $item_id => $result) { + // Load the entity if it is one of the relationship entities. + list($datasource_id, $raw_id) = Utility::splitCombinedId($item_id); + if (isset($entities_to_load[$this->index->getDatasource($datasource_id)->getEntityTypeId()])) { + $this->loadResultEntity($results, $result, $view->relationship, $entities_to_load); + } + } + } + + private function loadResultEntity(&$results, $result, $relationships, $entities_to_load) { + $item = $this->index->loadItem($result->search_api_id); + if ($item) { + $item_id = $result->search_api_id; + $entity = $item->getValue(); + // Check access to the entity if access check on. + foreach ($entities_to_load[$entity->getEntityTypeId()] as $join) { + if ($relationships[$join['base']]->options['entity_access'] && !$entity->access('view')) { unset($results[$item_id]); + continue; } } + // Add entity to all relationships that use it. + // Multiple example: node and node_fields. + foreach ($entities_to_load[$entity->getEntityTypeId()] as $join) { + $results[$item_id]->_relationship_entities[$join['relationship_id']] = $entity; + } } + else { + // No entity. If it is required remove the row. + foreach ($entities_to_load[$entity->getEntityTypeId()] as $join) { + if ($relationships[$join['base']]->options['required']) { + unset($results[$item_id]); + continue; + } + } + } + } + + protected function retrieveItemResults(array $results) { + /** @var \Drupal\views\ResultRow[] $rows */ + $rows = array(); + + // Views \Drupal\views\Plugin\views\style\StylePluginBase::renderFields() + // uses a numeric results index to key the rendered results. + // The Row::index is the key then used to retrieve these. + $count = 0; - // First off, we try to gather as much field values as possible without - // loading any items. foreach ($results as $item_id => $result) { $datasource_id = $result->getDatasourceId(); @@ -466,51 +563,23 @@ class SearchApiQuery extends QueryPluginBase { $values['search_api_excerpt'] = $result->getExcerpt() ?: ''; // Gather any fields from the search results. - foreach ($result->getFields(FALSE) as $field_id => $field) { + foreach ($result->getFields() as $field_id => $field) { if ($field->getValues()) { - $values[$field_id] = $field->getValues(); + // @todo so results from fields here are always arrays; even when single. + // What are the different handling in views when something _can_ be multiple, + // and when it should be single? + $values[$field_id] = $field->getValues()[0]; } } - // Check whether we need to extract any properties from the result item. - $missing_fields = array_diff_key($this->fields, $values); - if ($missing_fields) { - $missing[$item_id] = $missing_fields; - if (!is_object($values['_item'])) { - $item_ids[] = $item_id; - } - } + $values['index'] = $count++; - // Save the row values for adding them to the Views result afterwards. // @todo Use a custom sub-class here to also pass the result item object, // or other information? $rows[$item_id] = new ResultRow($values); } - // Load items of those rows which haven't got all field values, yet. - if (!empty($item_ids)) { - foreach ($this->index->loadItemsMultiple($item_ids) as $item_id => $object) { - $results[$item_id]->setOriginalObject($object); - $rows[$item_id]->_item = $object; - } - } - - foreach ($missing as $item_id => $missing_fields) { - foreach ($missing_fields as $field_id) { - // @todo This will only extract the indexed fields, but all of them. We - // should instead use Utility::extractFields() directly. - $field = $results[$item_id]->getField($field_id); - if ($field) { - $rows[$item_id]->$field_id = $field->getValues(); - } - } - } - - // 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 $rows; } /** @@ -874,13 +943,13 @@ class SearchApiQuery extends QueryPluginBase { * The option's previous value, or NULL if none was set. * * @see \Drupal\search_api\Query\QueryInterface::setOption() - */ - public function setOption($name, $value) { - if (!$this->shouldAbort()) { - return $this->query->setOption($name, $value); - } - return NULL; - } + */ + public function setOption($name, $value) { + if (!$this->shouldAbort()) { + return $this->query->setOption($name, $value); + } + return NULL; + } /** * Retrieves all options set for this search query. @@ -910,4 +979,7 @@ class SearchApiQuery extends QueryPluginBase { return $dependencies; } + // Views assumes this function to exist. + public function ensureTable() {} + } diff --git a/src/Plugin/views/relationship/DatasourceEntity.php b/src/Plugin/views/relationship/DatasourceEntity.php new file mode 100644 index 0000000..e574085 --- /dev/null +++ b/src/Plugin/views/relationship/DatasourceEntity.php @@ -0,0 +1,59 @@ + 'checkbox', + '#title' => $this->t('Check view access permissions'), + '#description' => $this->t('Enable to check user has permission to view the entity. 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 ressort (and in addition to other checks, if possible).'), + '#default_value' => !empty($this->options['required']), + ); + } + + + public function query() { + return; + } + + public function calculateDependencies() { + // Todo Guess this should check the datasources has the entity? + } +} diff --git a/src/Plugin/views/row/SearchApiRow.php b/src/Plugin/views/row/SearchApiRow.php index f1a73d3..6130d63 100644 --- a/src/Plugin/views/row/SearchApiRow.php +++ b/src/Plugin/views/row/SearchApiRow.php @@ -24,7 +24,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; * * @ViewsRow( * id = "search_api", - * title = @Translation("Rendered Search API item"), + * title = @Translation("Rendered entity"), * help = @Translation("Displays entity of the matching search API item"), * ) */ @@ -118,7 +118,6 @@ class SearchApiRow extends RowPluginBase { */ public function init(ViewExecutable $view, DisplayPluginBase $display, array &$options = NULL) { parent::init($view, $display, $options); - $base_table = $view->storage->get('base_table'); $this->index = SearchApiQuery::getIndexFromTable($base_table, $this->getEntityManager()); if (!$this->index) {