diff --git a/README.txt b/README.txt index 5c1bce0..1aba980 100644 --- a/README.txt +++ b/README.txt @@ -21,9 +21,9 @@ numerous ways of extension the module provides. Hence, the growing number of additional contrib modules, providing additional functionality or helping users customize some aspects of the search process. * For a full description of the module, visit the project page: - https://drupal.org/project/search_api + https://www.drupal.org/project/search_api * To submit bug reports and feature suggestions, or to track changes: - https://drupal.org/project/issues/search_api + https://www.drupal.org/project/issues/search_api REQUIREMENTS ------------ @@ -32,7 +32,7 @@ No other modules required INSTALLATION ------------ * Install as you would normally install a contributed drupal module. See: - https://drupal.org/documentation/install/modules-themes/modules-7 + https://www.drupal.org/documentation/install/modules-themes/modules-8 for further information. CONFIGURATION @@ -50,4 +50,4 @@ Todo MAINTAINERS ----------- Current maintainers: - * Thomas Seidl (drunken monkey) - https://drupal.org/user/205582 + * Thomas Seidl (drunken monkey) - https://www.drupal.org/u/drunken-monkey diff --git a/config/schema/search_api.views.schema.yml b/config/schema/search_api.views.schema.yml index e7ae09f..cca7463 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.api.php b/search_api.api.php index 87e25ce..b2a8c63 100644 --- a/search_api.api.php +++ b/search_api.api.php @@ -98,6 +98,34 @@ function hook_search_api_field_type_mapping_alter(array &$mapping) { } /** + * Alter the mapping of Search API data types to their default Views handlers. + * + * @param array $mapping + * An associative array with data types as the keys and Views field data + * definitions as the values. In addition to all normally defined data types, + * keys can also be "options" for any field with an options list, "entity" for + * general entity-typed fields or "entity:ENTITY_TYPE" (with "ENTITY_TYPE" + * being the machine name of an entity type) for entities of that type. + */ +function 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', + ), + 'sort' => array( + 'id' => 'my_entity_type', + ), + ); + $mapping['date']['filter']['id'] = 'my_date_filter'; +} + +/** * 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 c242642..c49b6ca 100644 --- a/search_api.views.inc +++ b/search_api.views.inc @@ -5,71 +5,68 @@ * Views hook implementations for the Search API module. */ +use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\search_api\Entity\Index; +use Drupal\Component\Utility\NestedArray; +use Drupal\search_api\Item\FieldInterface; +use Drupal\search_api\Property\PropertyInterface; +use Drupal\search_api\SearchApiException; +use Drupal\search_api\Utility; /** * 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; } @@ -96,3 +93,291 @@ function search_api_views_plugins_row_alter(array &$plugins) { } $plugins['search_api']['base'] = $bases; } + +function _search_api_views_data_special_fields(&$table, $index) { + 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 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'; + $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). +} + + +function _search_api_views_data_searchapi_fields(&$table, $index) { + /** @var \Drupal\search_api\Item\FieldInterface $field */ + 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); + if ($field_definition) { + $field_definition += array( + 'title' => $field->getLabel(), + 'help' => $field->getDescription(), + ); + if ($definition = $field->getDataDefinition()) { + $field_definition['title short'] = $definition->getLabel(); + } + if ($field_id != $field_alias) { + $field_definition['real field'] = $field_id; + } + $table[$field_alias] = $field_definition; + } + } +} + +/** + * Finds an unused field alias for a field in a Views table definition. + * + * @param string $field_id + * The original ID of the Search API field. + * @param array $table + * The Views table definition. + * + * @return string + * The field alias to use. + */ +function _search_api_views_find_field_alias($field_id, array &$table) { + $base = $field_alias = preg_replace('/[^a-zA-Z0-9]+/S', '_', $field_id); + $i = 0; + while (isset($table[$field_alias])) { + $field_alias = $base . '_' . ++$i; + } + return $field_alias; +} + +/** + * Returns the Views handlers to use for a given field. + * + * @param \Drupal\search_api\Item\FieldInterface $field + * The field to add to the definition. + * + * @return array + * The Views definition to add for the given field. + */ +function _search_api_views_get_handlers(FieldInterface $field) { + $mapping = _search_api_views_handler_mapping(); + + try { + $types = array(); + $definition = $field->getDataDefinition(); + + while ($definition instanceof PropertyInterface && $definition != $definition->getWrappedProperty()) { + $definition = $definition->getWrappedProperty(); + } + + if ($definition instanceof FieldDefinitionInterface) { + if ($definition->getType() == 'entity_reference') { + $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 */ + $data_type = Utility::getDataTypePluginManager()->createInstance($field->getType()); + if (!$data_type->isDefault()) { + $types[] = $data_type->getFallbackType(); + } + + foreach ($types as $type) { + if (isset($mapping[$type])) { + _search_api_views_handler_adjustments($type, $field, $mapping[$type]); + return $mapping[$type]; + } + } + } + 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); + } + + 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 + * 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. + * + * @see search_api_views_handler_mapping_alter() + */ +function _search_api_views_handler_mapping() { + $mapping = &drupal_static(__FUNCTION__); + + if (!isset($mapping)) { + $mapping = array( + 'boolean' => array( + 'field' => array( + 'id' => 'boolean', + ), + 'filter' => array( + 'id' => 'search_api_boolean', + ), + 'sort' => array( + 'id' => 'search_api', + ), + ), + 'date' => array( + 'field' => array( + 'id' => 'date', + ), + 'filter' => array( + 'id' => 'search_api_date', + ), + 'sort' => array( + 'id' => 'search_api', + ), + ), + 'decimal' => array( + 'field' => array( + 'id' => 'decimal', + ), + 'type' => 'numeric', + 'filter' => array( + 'id' => 'search_api_numeric', + ), + 'sort' => array( + 'id' => 'search_api', + ), + ), + 'integer' => array( + 'field' => array( + 'id' => 'integer', + ), + 'type' => 'numeric', + 'filter' => array( + 'id' => 'search_api_numeric', + ), + 'sort' => array( + 'id' => 'search_api', + ), + ), + 'string' => array( + 'field' => array( + 'id' => 'standard', + ), + 'filter' => array( + 'id' => 'search_api_string', + ), + 'sort' => array( + 'id' => 'search_api', + ), + ), + 'text' => array( + 'field' => array( + 'id' => 'markup', + // @todo $format REQUIRED. + ), + 'filter' => array( + 'id' => 'search_api_text', + ), + 'sort' => array( + 'id' => 'search_api', + ), + ), + 'options' => array( + 'filter' => array( + 'id' => 'search_api_options', + ), + 'sort' => array( + 'id' => 'search_api', + ), + ), + 'entity:taxonomy_term' => array( + 'field' => array( + 'id' => 'standard_list', + ), + 'filter' => array( + 'id' => 'search_api_term', + ), + 'sort' => array( + 'id' => 'search_api', + ), + ), + 'entity:user' => array( + 'field' => array( + 'id' => 'standard', + ), + 'filter' => array( + 'id' => 'search_api_user', + ), + 'sort' => array( + 'id' => 'search_api', + ), + ), + ); + + $alter_id = 'search_api_views_handler_mapping'; + \Drupal::moduleHandler()->alter($alter_id, $mapping); + } + + return $mapping; +} diff --git a/search_api_db/src/Plugin/search_api/backend/Database.php b/search_api_db/src/Plugin/search_api/backend/Database.php index ae79152..cccf00d 100644 --- a/search_api_db/src/Plugin/search_api/backend/Database.php +++ b/search_api_db/src/Plugin/search_api/backend/Database.php @@ -206,7 +206,7 @@ class Database extends BackendPluginBase { * The data type plugin manager. */ public function getDataTypePluginManager() { - return $this->dataTypePluginManager ?: \Drupal::service('plugin.manager.search_api.data_type'); + return $this->dataTypePluginManager ?: Utility::getDataTypePluginManager(); } /** diff --git a/src/DataType/DataTypePluginBase.php b/src/DataType/DataTypePluginBase.php index 827d2c0..191bfde 100644 --- a/src/DataType/DataTypePluginBase.php +++ b/src/DataType/DataTypePluginBase.php @@ -9,6 +9,7 @@ namespace Drupal\search_api\DataType; use Drupal\Core\Plugin\PluginBase; use Drupal\search_api\Backend\BackendPluginManager; +use Drupal\search_api\Utility; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -69,7 +70,7 @@ abstract class DataTypePluginBase extends PluginBase implements DataTypeInterfac * The backend plugin manager. */ public function getBackendManager() { - return $this->backendManager ?: \Drupal::service('plugin.manager.search_api.backend'); + return $this->backendManager ?: Utility::getBackendPluginManager(); } /** diff --git a/src/Entity/Index.php b/src/Entity/Index.php index 9d741f1..3142b07 100644 --- a/src/Entity/Index.php +++ b/src/Entity/Index.php @@ -368,8 +368,7 @@ class Index extends ConfigEntityBase implements IndexInterface { public function getDatasources($only_enabled = TRUE) { if (!isset($this->datasourcePlugins)) { $this->datasourcePlugins = array(); - /** @var $datasource_plugin_manager \Drupal\search_api\Datasource\DatasourcePluginManager */ - $datasource_plugin_manager = \Drupal::service('plugin.manager.search_api.datasource'); + $datasource_plugin_manager = Utility::getDatasourcePluginManager(); foreach ($datasource_plugin_manager->getDefinitions() as $name => $datasource_definition) { if (class_exists($datasource_definition['class']) && empty($this->datasourcePlugins[$name])) { @@ -398,7 +397,7 @@ class Index extends ConfigEntityBase implements IndexInterface { * {@inheritdoc} */ public function hasValidTracker() { - return (bool) \Drupal::service('plugin.manager.search_api.tracker')->getDefinition($this->getTrackerId(), FALSE); + return (bool) Utility::getTrackerPluginManager()->getDefinition($this->getTrackerId(), FALSE); } /** @@ -414,7 +413,7 @@ class Index extends ConfigEntityBase implements IndexInterface { public function getTracker() { if (!$this->trackerPlugin) { $tracker_plugin_configuration = array('index' => $this) + $this->tracker_config; - if (!($this->trackerPlugin = \Drupal::service('plugin.manager.search_api.tracker')->createInstance($this->getTrackerId(), $tracker_plugin_configuration))) { + if (!($this->trackerPlugin = Utility::getTrackerPluginManager()->createInstance($this->getTrackerId(), $tracker_plugin_configuration))) { $args['@tracker'] = $this->tracker; $args['%index'] = $this->label(); throw new SearchApiException(new FormattableMarkup('The tracker with ID "@tracker" could not be retrieved for index %index.', $args)); @@ -524,8 +523,7 @@ class Index extends ConfigEntityBase implements IndexInterface { */ protected function loadProcessors() { if (!isset($this->processors)) { - /** @var $processor_plugin_manager \Drupal\search_api\Processor\ProcessorPluginManager */ - $processor_plugin_manager = \Drupal::service('plugin.manager.search_api.processor'); + $processor_plugin_manager = Utility::getProcessorPluginManager(); $processor_settings = $this->getOption('processors', array()); foreach ($processor_plugin_manager->getDefinitions() as $name => $processor_definition) { diff --git a/src/Entity/Server.php b/src/Entity/Server.php index fac5f8f..ae7c973 100644 --- a/src/Entity/Server.php +++ b/src/Entity/Server.php @@ -112,7 +112,7 @@ class Server extends ConfigEntityBase implements ServerInterface { * {@inheritdoc} */ public function hasValidBackend() { - $backend_plugin_definition = \Drupal::service('plugin.manager.search_api.backend')->getDefinition($this->getBackendId(), FALSE); + $backend_plugin_definition = Utility::getBackendPluginManager()->getDefinition($this->getBackendId(), FALSE); return !empty($backend_plugin_definition); } @@ -128,7 +128,7 @@ class Server extends ConfigEntityBase implements ServerInterface { */ public function getBackend() { if (!$this->backendPlugin) { - $backend_plugin_manager = \Drupal::service('plugin.manager.search_api.backend'); + $backend_plugin_manager = Utility::getBackendPluginManager(); $config = $this->backend_config; $config['server'] = $this; if (!($this->backendPlugin = $backend_plugin_manager->createInstance($this->getBackendId(), $config))) { diff --git a/src/Form/IndexForm.php b/src/Form/IndexForm.php index a7bb1bd..0b44eb6 100644 --- a/src/Form/IndexForm.php +++ b/src/Form/IndexForm.php @@ -97,7 +97,7 @@ class IndexForm extends EntityForm { * The datasource plugin manager. */ protected function getDatasourcePluginManager() { - return $this->datasourcePluginManager ?: \Drupal::service('plugin.manager.search_api.datasource'); + return $this->datasourcePluginManager ?: Utility::getDatasourcePluginManager(); } /** @@ -107,7 +107,7 @@ class IndexForm extends EntityForm { * The tracker plugin manager. */ protected function getTrackerPluginManager() { - return $this->trackerPluginManager ?: \Drupal::service('plugin.manager.search_api.tracker'); + return $this->trackerPluginManager ?: Utility::getTrackerPluginManager(); } /** diff --git a/src/Form/ServerForm.php b/src/Form/ServerForm.php index 862da9e..5e1dd61 100644 --- a/src/Form/ServerForm.php +++ b/src/Form/ServerForm.php @@ -15,6 +15,7 @@ use Drupal\Core\Url; use Drupal\search_api\Backend\BackendPluginManager; use Drupal\search_api\SearchApiException; use Drupal\search_api\ServerInterface; +use Drupal\search_api\Utility; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -77,7 +78,7 @@ class ServerForm extends EntityForm { * The backend plugin manager. */ protected function getBackendPluginManager() { - return $this->backendPluginManager ?: \Drupal::service('plugin.manager.search_api.backend'); + return $this->backendPluginManager ?: Utility::getBackendPluginManager(); } /** 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 @@ + $this->t('Is less than'), - '<=' => $this->t('Is less than or equal to'), - '=' => $this->t('Is equal to'), - '<>' => $this->t('Is not equal to'), - '>=' => $this->t('Is greater than or equal to'), - '>' => $this->t('Is greater than'), - 'empty' => $this->t('Is empty'), - 'not empty' => $this->t('Is not empty'), - ); - } - - /** - * {@inheritdoc} - */ - protected function valueForm(&$form, FormStateInterface $form_state) { - // @todo Hopefully we can now be more sure of what we get in $this->value. - while (is_array($this->value) && count($this->value) < 2) { - $this->value = $this->value ? reset($this->value) : NULL; - } - $form['value'] = array( - '#type' => 'textfield', - '#title' => !$form_state->get('exposed') ? $this->t('Value') : '', - '#size' => 30, - '#default_value' => isset($this->value) ? $this->value : '', - ); - - // Hide the value box if the operator is 'empty' or 'not empty'. - // Radios share the same selector so we have to add some dummy selector. - if (!$form_state->get('exposed')) { - $form['value']['#states']['visible'] = array( - ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'), - ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'), - ); - } - elseif (!empty($this->options['expose']['use_operator'])) { - $name = $this->options['expose']['operator_id']; - $form['value']['#states']['visible'] = array( - ':input[name="' . $name . '"],dummy-empty' => array('!value' => 'empty'), - ':input[name="' . $name . '"],dummy-not-empty' => array('!value' => 'not empty'), - ); - } - } - - /** - * {@inheritdoc} - */ - public function adminSummary() { - if (!empty($this->options['exposed'])) { - return $this->t('exposed'); - } - - if ($this->operator === 'empty') { - return $this->t('is empty'); - } - if ($this->operator === 'not empty') { - return $this->t('is not empty'); - } - - return Html::escape((string) $this->operator) . ' ' . Html::escape((string) $this->value); - } - - /** - * {@inheritdoc} - */ - public function query() { - if ($this->operator === 'empty') { - $this->query->condition($this->realField, NULL, '=', $this->options['group']); - } - elseif ($this->operator === 'not empty') { - $this->query->condition($this->realField, NULL, '<>', $this->options['group']); - } - else { - while (is_array($this->value)) { - $this->value = $this->value ? reset($this->value) : NULL; - } - if (strlen($this->value) > 0) { - $this->query->condition($this->realField, $this->value, $this->operator, $this->options['group']); - } - } - } - -} diff --git a/src/Plugin/views/filter/SearchApiFilterBoolean.php b/src/Plugin/views/filter/SearchApiFilterBoolean.php index c18fe16..a0f1c45 100644 --- a/src/Plugin/views/filter/SearchApiFilterBoolean.php +++ b/src/Plugin/views/filter/SearchApiFilterBoolean.php @@ -7,7 +7,7 @@ namespace Drupal\search_api\Plugin\views\filter; -use Drupal\Core\Form\FormStateInterface; +use Drupal\views\Plugin\views\filter\BooleanOperator; /** * Defines a filter for filtering on boolean values. @@ -16,28 +16,8 @@ use Drupal\Core\Form\FormStateInterface; * * @ViewsFilter("search_api_boolean") */ -class SearchApiFilterBoolean extends SearchApiFilter { +class SearchApiFilterBoolean extends BooleanOperator { - /** - * {@inheritdoc} - */ - public function operatorOptions() { - return array(); - } - - /** - * {@inheritdoc} - */ - protected function valueForm(&$form, FormStateInterface $form_state) { - while (is_array($this->value)) { - $this->value = $this->value ? array_shift($this->value) : NULL; - } - $form['value'] = array( - '#type' => 'select', - '#title' => !$form_state->get('exposed') ? $this->t('Value') : '', - '#options' => array(1 => $this->t('True'), 0 => $this->t('False')), - '#default_value' => isset($this->value) ? $this->value : '', - ); - } + use SearchApiFilterTrait; } diff --git a/src/Plugin/views/filter/SearchApiFilterDatasource.php b/src/Plugin/views/filter/SearchApiFilterDatasource.php new file mode 100644 index 0000000..9ed5382 --- /dev/null +++ b/src/Plugin/views/filter/SearchApiFilterDatasource.php @@ -0,0 +1,39 @@ +valueOptions)) { + return $this->valueOptions; + } + + $this->valueOptions = array(); + + $index = $this->getIndex(); + if ($index) { + foreach ($index->getDatasources() as $datasource_id => $datasource) { + $this->valueOptions[$datasource_id] = $datasource->label(); + } + } + + return $this->valueOptions; + } + +} diff --git a/src/Plugin/views/filter/SearchApiFilterDate.php b/src/Plugin/views/filter/SearchApiFilterDate.php index 1566d81..d684c77 100644 --- a/src/Plugin/views/filter/SearchApiFilterDate.php +++ b/src/Plugin/views/filter/SearchApiFilterDate.php @@ -7,7 +7,7 @@ namespace Drupal\search_api\Plugin\views\filter; -use Drupal\Core\Form\FormStateInterface; +use Drupal\views\Plugin\views\filter\Date; /** * Defines a filter for filtering on dates. @@ -16,87 +16,19 @@ use Drupal\Core\Form\FormStateInterface; * * @ViewsFilter("search_api_date") */ -class SearchApiFilterDate extends SearchApiFilter { +class SearchApiFilterDate extends Date { - /** - * {@inheritdoc} - */ - public function defineOptions() { - return parent::defineOptions() + array( - 'widget_type' => array('default' => 'default'), - ); - } - - /** - * {@inheritdoc} - */ - public function hasExtraOptions() { - if (\Drupal::moduleHandler()->moduleExists('date_popup')) { - return TRUE; - } - return FALSE; - } - - /** - * {@inheritdoc} - */ - public function buildExtraOptionsForm(&$form, FormStateInterface $form_state) { - parent::buildExtraOptionsForm($form, $form_state); - if (\Drupal::moduleHandler()->moduleExists('date_popup')) { - $widget_options = array( - 'default' => $this->t('Default'), - 'date_popup' => $this->t('Date popup'), - ); - $form['widget_type'] = array( - '#type' => 'radios', - '#title' => $this->t('Date selection form element'), - '#default_value' => $this->options['widget_type'], - '#options' => $widget_options, - ); - } - } - - /** - * {@inheritdoc} - */ - protected function valueForm(&$form, FormStateInterface $form_state) { - parent::valueForm($form, $form_state); - - // If we are using the date popup widget, overwrite the settings of the form - // according to what date_popup expects. - if ($this->options['widget_type'] == 'date_popup' && \Drupal::moduleHandler()->moduleExists('date_popup')) { - $form['value']['#type'] = 'date_popup'; - $form['value']['#date_format'] = 'm/d/Y'; - unset($form['value']['#description']); - } - elseif (!$form_state->get('exposed')) { - $form['value']['#description'] = $this->t('A date in any format understood by PHP. For example, "@date1" or "@date2".', array( - '@doc-link' => 'http://php.net/manual/en/function.strtotime.php', - '@date1' => format_date(REQUEST_TIME, 'custom', 'Y-m-d H:i:s'), - '@date2' => 'now + 1 day', - )); - } - } + use SearchApiFilterTrait; /** * {@inheritdoc} */ - public function query() { - if ($this->operator === 'empty') { - $this->query->condition($this->realField, NULL, '=', $this->options['group']); - } - elseif ($this->operator === 'not empty') { - $this->query->condition($this->realField, NULL, '<>', $this->options['group']); - } - else { - while (is_array($this->value)) { - $this->value = $this->value ? reset($this->value) : NULL; - } - $v = is_numeric($this->value) ? $this->value : strtotime($this->value, REQUEST_TIME); - if ($v !== FALSE) { - $this->query->condition($this->realField, $v, $this->operator, $this->options['group']); - } - } + public function operators() { + $operators = parent::operators(); + // @todo Enable "(not) between" again once that operator is available in + // the Search API. + unset($operators['between'], $operators['not between'], $operators['regular_expression']); + return $operators; } } diff --git a/src/Plugin/views/filter/SearchApiFilterEntityBase.php b/src/Plugin/views/filter/SearchApiFilterEntityBase.php deleted file mode 100644 index f05e486..0000000 --- a/src/Plugin/views/filter/SearchApiFilterEntityBase.php +++ /dev/null @@ -1,219 +0,0 @@ - $this->isMultiValued() ? $this->t('Is one of') : $this->t('Is'), - 'all of' => $this->t('Is all of'), - '<>' => $this->isMultiValued() ? $this->t('Is not one of') : $this->t('Is not'), - 'empty' => $this->t('Is empty'), - 'not empty' => $this->t('Is not empty'), - ); - if (!$this->isMultiValued()) { - unset($operators['all of']); - } - return $operators; - } - - /** - * {@inheritdoc} - */ - public function defineOptions() { - $options = parent::defineOptions(); - - $options['expose']['multiple']['default'] = TRUE; - - return $options; - } - - /** - * {@inheritdoc} - */ - protected function valueForm(&$form, FormStateInterface $form_state) { - parent::valueForm($form, $form_state); - - if (!is_array($this->value)) { - $this->value = $this->value ? array($this->value) : array(); - } - - // Set the correct default value in case the admin-set value is used (and a - // value is present). The value is used if the form is either not exposed, - // or the exposed form wasn't submitted yet. (There doesn't seem to be an - // easier way to check for that.) - if ($this->value && (!$form_state->getUserInput() || !empty($form_state->getUserInput()['live_preview']))) { - $form['value']['#default_value'] = $this->idsToString($this->value); - } - } - - /** - * {@inheritdoc} - */ - public function valueValidate($form, FormStateInterface $form_state) { - if (!empty($form['value'])) { - $form_values = $form_state->getValues(); - $value = $form_values['options']['value']; - $values = $this->isMultiValued($form_values['options']) ? Tags::explode($value) : array($value); - $ids = $this->validateEntityStrings($form['value'], $values, $form_state); - - if ($ids) { - $value = $ids; - $form_state->setValue('value', $value); - } - } - } - - /** - * {@inheritdoc} - */ - public function acceptExposedInput($input) { - $rc = parent::acceptExposedInput($input); - - if ($rc) { - // If we have previously validated input, override. - if ($this->validatedExposedInput) { - $this->value = $this->validatedExposedInput; - } - } - - return $rc; - } - - /** - * {@inheritdoc} - */ - public function validateExposed(&$form, FormStateInterface $form_state) { - if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) { - return; - } - - $identifier = $this->options['expose']['identifier']; - $input = $form_state->getValues()[$identifier]; - - if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) { - $this->operator = $this->options['group_info']['group_items'][$input]['operator']; - $input = $this->options['group_info']['group_items'][$input]['value']; - } - - $values = $this->isMultiValued() ? Tags::explode($input) : array($input); - - if (!$this->options['is_grouped'] || ($this->options['is_grouped'] && ($input != 'All'))) { - $this->validatedExposedInput = $this->validateEntityStrings($form[$identifier], $values, $form_state); - } - else { - $this->validatedExposedInput = FALSE; - } - } - - /** - * Determines whether multiple values can be entered into this filter. - * - * This is either the case if the form isn't exposed, or if the " Allow - * multiple selections" option is enabled. - * - * @param array $options - * (optional) The options array to use. If not supplied, the options set on - * this filter will be used. - * - * @return bool - * TRUE if multiple values can be entered for this filter, FALSE otherwise. - */ - protected function isMultiValued(array $options = array()) { - $options = $options ?: $this->options; - return empty($options['exposed']) || !empty($options['expose']['multiple']); - } - - /** - * {@inheritdoc} - */ - public function adminSummary() { - if (!is_array($this->value)) { - $this->value = $this->value ? array($this->value) : array(); - } - $value = $this->value; - $this->value = empty($value) ? '' : $this->idsToString($value); - $ret = parent::adminSummary(); - $this->value = $value; - return $ret; - } - - /** - * {@inheritdoc} - */ - public function query() { - if ($this->operator === 'empty') { - $this->query->condition($this->realField, NULL, '=', $this->options['group']); - } - elseif ($this->operator === 'not empty') { - $this->query->condition($this->realField, NULL, '<>', $this->options['group']); - } - elseif (is_array($this->value)) { - $all_of = $this->operator === 'all of'; - $operator = $all_of ? '=' : $this->operator; - if (count($this->value) == 1) { - $this->query->condition($this->realField, reset($this->value), $operator, $this->options['group']); - } - else { - $filter = $this->query->createFilter($operator === '<>' || $all_of ? 'AND' : 'OR'); - foreach ($this->value as $value) { - $filter->condition($this->realField, $value, $operator); - } - $this->query->filter($filter, $this->options['group']); - } - } - } - -} diff --git a/src/Plugin/views/filter/SearchApiFilterNumeric.php b/src/Plugin/views/filter/SearchApiFilterNumeric.php new file mode 100644 index 0000000..e5b6a37 --- /dev/null +++ b/src/Plugin/views/filter/SearchApiFilterNumeric.php @@ -0,0 +1,41 @@ +getQuery()->condition($this->realField, NULL, $this->operator == 'empty' ? '=' : '<>', $this->options['group']); + } + +} diff --git a/src/Plugin/views/filter/SearchApiFilterOptions.php b/src/Plugin/views/filter/SearchApiFilterOptions.php index b42b173..6235505 100644 --- a/src/Plugin/views/filter/SearchApiFilterOptions.php +++ b/src/Plugin/views/filter/SearchApiFilterOptions.php @@ -7,9 +7,8 @@ namespace Drupal\search_api\Plugin\views\filter; -use Drupal\Component\Utility\Html; -use Drupal\Component\Utility\Unicode; use Drupal\Core\Form\FormStateInterface; +use Drupal\views\Plugin\views\filter\ManyToOne; /** * Defines a filter for filtering on fields with a fixed set of possible values. @@ -18,279 +17,17 @@ use Drupal\Core\Form\FormStateInterface; * * @ViewsFilter("search_api_options") */ -class SearchApiFilterOptions extends SearchApiFilter { +class SearchApiFilterOptions extends ManyToOne { - /** - * Stores the values which are available on the form. - * - * @var array|null - */ - protected $valueOptions; - - /** - * The type of form element used to display the options. - * - * @var string - */ - protected $valueFormType = 'checkboxes'; - - /** - * Fills SearchApiFilterOptions::$valueOptions with all possible options. - */ - protected function getValueOptions() { - if (isset($this->valueOptions)) { - return; - } - - // @todo This obviously needs a different solution. - $wrapper = $this->get_wrapper(); - if ($wrapper) { - $this->valueOptions = $wrapper->optionsList('view'); - } - else { - $this->valueOptions = array(); - } - } - - /** - * {@inheritdoc} - */ - public function operatorOptions() { - $options = array( - '=' => $this->t('Is one of'), - 'all of' => $this->t('Is all of'), - '<>' => $this->t('Is none of'), - 'empty' => $this->t('Is empty'), - 'not empty' => $this->t('Is not empty'), - ); - // "Is all of" doesn't make sense for single-valued fields. - if (empty($this->definition['multi-valued'])) { - unset($options['all of']); - } - return $options; - } - - /** - * {@inheritdoc} - */ - public function defineOptions() { - $options = parent::defineOptions(); - $options['expose']['contains']['reduce'] = array('default' => FALSE); - return $options; - } - - /** - * {@inheritdoc} - */ - public function defaultExposeOptions() { - parent::defaultExposeOptions(); - $this->options['expose']['reduce'] = FALSE; - } - - /** - * {@inheritdoc} - */ - public function buildExposedForm(&$form, FormStateInterface $form_state) { - parent::buildExposedForm($form, $form_state); - $form['expose']['reduce'] = array( - '#type' => 'checkbox', - '#title' => $this->t('Limit list to selected items'), - '#description' => $this->t('If checked, the only items presented to the user will be the ones selected here.'), - '#default_value' => !empty($this->options['expose']['reduce']), - ); - } + use SearchApiFilterTrait; /** * {@inheritdoc} */ - protected function valueForm(&$form, FormStateInterface $form_state) { - $this->getValueOptions(); - if (!empty($this->options['expose']['reduce']) && $form_state->get('exposed')) { - $options = $this->reduceValueOptions(); - } - else { - $options = $this->valueOptions; - } - - $form['value'] = array( - '#type' => $this->valueFormType, - '#title' => !$form_state->get('exposed') ? $this->t('Value') : '', - '#options' => $options, - '#multiple' => TRUE, - '#size' => min(4, count($options)), - '#default_value' => is_array($this->value) ? $this->value : array(), - ); - - // Hide the value box if the operator is 'empty' or 'not empty'. - // Radios share the same selector so we have to add some dummy selector. - if (!$form_state->get('exposed')) { - $form['value']['#states']['visible'] = array( - ':input[name="options[operator]"],dummy-empty' => array('!value' => 'empty'), - ':input[name="options[operator]"],dummy-not-empty' => array('!value' => 'not empty'), - ); - } - elseif (!empty($this->options['expose']['use_operator'])) { - $name = $this->options['expose']['operator_id']; - $form['value']['#states']['visible'] = array( - ':input[name="' . $name . '"],dummy-empty' => array('!value' => 'empty'), - ':input[name="' . $name . '"],dummy-not-empty' => array('!value' => 'not empty'), - ); - } - } - - /** - * Retrieves the reduced options list to use for the exposed filter. - * - * @return string[] - * An options list for the values list, with only the ones selected in the - * admin UI included. - */ - protected function reduceValueOptions() { - foreach ($this->valueOptions as $id => $option) { - if (!isset($this->options['value'][$id])) { - unset($this->valueOptions[$id]); - } - } - return $this->valueOptions; - } - - /** - * {@inheritdoc} - */ - // @todo Is this still needed in D8? - public function valueSubmit($form, FormStateInterface $form_state) { - // Drupal's FAPI system automatically puts '0' in for any checkbox that - // was not set, and the key to the checkbox if it is set. - // Unfortunately, this means that if the key to that checkbox is 0, - // we are unable to tell if that checkbox was set or not. - - // Luckily, the '#value' on the checkboxes form actually contains - // *only* a list of checkboxes that were set, and we can use that - // instead. - - $form_state->setValueForElement($form['value'], $form['value']['#value']); - } - - /** - * {@inheritdoc} - */ - public function adminSummary() { - if (!empty($this->options['exposed'])) { - return $this->t('exposed'); - } - - if ($this->operator === 'empty') { - return $this->t('is empty'); - } - if ($this->operator === 'not empty') { - return $this->t('is not empty'); - } - - if (!is_array($this->value)) { - return ''; - } - - $operator_options = $this->operatorOptions(); - $operator = $operator_options[$this->operator]; - $values = ''; - - // Remove every element which is not known. - // @todo Why? Doesn't FAPI already prevent this? - $this->getValueOptions(); - foreach ($this->value as $i => $value) { - if (!isset($this->valueOptions[$value])) { - unset($this->value[$i]); - } - } - // Choose different kind of ouput for 0, a single and multiple values. - if (count($this->value) == 0) { - return $this->operator != '<>' ? $this->t('none') : $this->t('any'); - } - elseif (count($this->value) == 1) { - switch ($this->operator) { - case '=': - case 'all of': - $operator = '='; - break; - - case '<>': - $operator = '<>'; - break; - } - // If there is only a single value, use just the plain operator, = or <>. - $operator = Html::escape($operator); - $values = Html::escape($this->valueOptions[reset($this->value)]); - } - else { - foreach ($this->value as $value) { - if ($values !== '') { - $values .= ', '; - } - if (Unicode::strlen($values) > 20) { - $values .= '…'; - break; - } - $values .= Html::escape($this->valueOptions[$value]); - } - } - - return $operator . (($values !== '') ? ' ' . $values : ''); - } - - /** - * {@inheritdoc} - */ - public function query() { - if ($this->operator === 'empty') { - $this->query->condition($this->realField, NULL, '=', $this->options['group']); - return; - } - if ($this->operator === 'not empty') { - $this->query->condition($this->realField, NULL, '<>', $this->options['group']); - return; - } - - // Extract the value. - while (is_array($this->value) && count($this->value) == 1) { - $this->value = reset($this->value); - } - - // Determine operator and conjunction. The defaults are already right for - // "all of". - $operator = '='; - $conjunction = 'AND'; - switch ($this->operator) { - case '=': - $conjunction = 'OR'; - break; - - case '<>': - $operator = '<>'; - break; - } - - // If the value is an empty array, we either want no filter at all (for - // "is none of"), or want to find only items with no value for the field. - if ($this->value === array()) { - if ($operator != '<>') { - $this->query->condition($this->realField, NULL, '=', $this->options['group']); - } - return; - } + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); - if (is_scalar($this->value) && $this->value !== '') { - $this->query->condition($this->realField, $this->value, $operator, $this->options['group']); - } - elseif ($this->value) { - $filter = $this->query->createFilter($conjunction); - // $filter will be NULL if there were errors in the query. - if ($filter) { - foreach ($this->value as $v) { - $filter->condition($this->realField, $v, $operator); - } - $this->query->filter($filter, $this->options['group']); - } - } + unset($form['reduce_duplicates']); } } diff --git a/src/Plugin/views/filter/SearchApiFilterString.php b/src/Plugin/views/filter/SearchApiFilterString.php new file mode 100644 index 0000000..992db5e --- /dev/null +++ b/src/Plugin/views/filter/SearchApiFilterString.php @@ -0,0 +1,24 @@ + $this->t('contains'), - '<>' => $this->t("doesn't contain"), - ); + public function operators() { + $operators = parent::operators(); + + $operators['=']['title'] = $this->t('contains'); + $operators['!=']['title'] = $this->t("doesn't contain"); + + $operators = array_intersect_key($operators, array('=' => 1, '!=' => 1)); + + return $operators; } } diff --git a/src/Plugin/views/filter/SearchApiFilterTrait.php b/src/Plugin/views/filter/SearchApiFilterTrait.php new file mode 100644 index 0000000..5e4e036 --- /dev/null +++ b/src/Plugin/views/filter/SearchApiFilterTrait.php @@ -0,0 +1,92 @@ +operatorValues(2)) { + unset($form['value']['min'], $form['value']['max']); + } + } + } + + /** + * 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 + * when "Many To One" is used as a base class. + * + * @see \Drupal\views\Plugin\views\filter\ManyToOne::opHelper() + */ + protected function opHelper() { + if (empty($this->value)) { + return; + } + // @todo Use "IN"/"NOT IN" instead, once available. + $conjunction = $this->operator == 'or' ? 'OR' : 'AND'; + $operator = $this->operator == 'not' ? '<>' : '='; + $filter = $this->getQuery()->createFilter($conjunction); + foreach ($this->value as $value) { + $filter->condition($this->realField, $value, $operator); + } + $this->getQuery()->filter($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/filter/SearchApiFulltext.php b/src/Plugin/views/filter/SearchApiFulltext.php index 0726e26..4efd1ff 100644 --- a/src/Plugin/views/filter/SearchApiFulltext.php +++ b/src/Plugin/views/filter/SearchApiFulltext.php @@ -11,6 +11,7 @@ use Drupal\Component\Utility\Unicode; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Render\Element; use Drupal\search_api\Entity\Index; +use Drupal\views\Plugin\views\filter\FilterPluginBase; /** * Defines a filter for adding a fulltext search to the view. @@ -19,24 +20,26 @@ use Drupal\search_api\Entity\Index; * * @ViewsFilter("search_api_fulltext") */ -class SearchApiFulltext extends SearchApiFilterText { +class SearchApiFulltext extends FilterPluginBase { + + use SearchApiFilterTrait; /** * {@inheritdoc} */ public function showOperatorForm(&$form, FormStateInterface $form_state) { - $this->operatorForm($form, $form_state); + parent::showOperatorForm($form, $form_state); $form['operator']['#description'] = $this->t('This operator only applies when using "Search keys" as the "Use as" setting.'); } /** * {@inheritdoc} */ - public function operatorOptions() { + public function operatorOptions($which = 'title') { return array( - 'AND' => $this->t('Contains all of these words'), - 'OR' => $this->t('Contains any of these words'), - 'NOT' => $this->t('Contains none of these words'), + 'and' => $this->t('Contains all of these words'), + 'or' => $this->t('Contains any of these words'), + 'not' => $this->t('Contains none of these words'), ); } @@ -46,7 +49,7 @@ class SearchApiFulltext extends SearchApiFilterText { public function defineOptions() { $options = parent::defineOptions(); - $options['operator']['default'] = 'AND'; + $options['operator']['default'] = 'and'; $options['min_length']['default'] = ''; $options['fields']['default'] = array(); @@ -94,6 +97,20 @@ class SearchApiFulltext extends SearchApiFilterText { /** * {@inheritdoc} */ + protected function valueForm(&$form, FormStateInterface $form_state) { + parent::valueForm($form, $form_state); + + $form['value'] = array( + '#type' => 'textfield', + '#title' => !$form_state->get('exposed') ? $this->t('Value') : '', + '#size' => 30, + '#default_value' => $this->value, + ); + } + + /** + * {@inheritdoc} + */ public function validateExposed(&$form, FormStateInterface $form_state) { // Only validate exposed input. if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) { @@ -106,7 +123,7 @@ class SearchApiFulltext extends SearchApiFilterText { } $identifier = $this->options['expose']['identifier']; - $input = &$form_state->getValues()[$identifier]; + $input = &$form_state->getValue($identifier, ''); if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) { $this->operator = $this->options['group_info']['group_items'][$input]['operator']; @@ -147,34 +164,38 @@ class SearchApiFulltext extends SearchApiFilterText { } $fields = $this->options['fields']; $fields = $fields ? $fields : array_keys($this->getFulltextFields()); + $query = $this->getQuery(); // If something already specifically set different fields, we silently fall // back to mere filtering. - $old = $this->query->getFulltextFields(); - $filter = $old && (array_diff($old, $fields) || array_diff($fields, $old)); + $filter = $this->options['mode'] == 'filter'; + if (!$filter) { + $old = $query->getFulltextFields(); + $filter = $old && (array_diff($old, $fields) || array_diff($fields, $old)); + } if ($filter) { - $filter = $this->query->createFilter('OR'); - $op = $this->operator === 'NOT' ? '<>' : '='; + $filter = $query->createFilter('OR'); + $op = $this->operator === 'not' ? '<>' : '='; foreach ($fields as $field) { $filter->condition($field, $this->value, $op); } - $this->query->filter($filter); + $query->filter($filter); return; } // If the operator was set to OR or NOT, set OR as the conjunction. (It is // also set for NOT since otherwise it would be "not all of these words".) - if ($this->operator != 'AND') { - $this->query->setOption('conjunction', 'OR'); + if ($this->operator != 'and') { + $query->setOption('conjunction', 'OR'); } - $this->query->setFulltextFields($fields); - $old = $this->query->getKeys(); - $old_original = $this->query->getOriginalKeys(); - $this->query->keys($this->value); - if ($this->operator == 'NOT') { - $keys = &$this->query->getKeys(); + $query->setFulltextFields($fields); + $old = $query->getKeys(); + $old_original = $query->getOriginalKeys(); + $query->keys($this->value); + if ($this->operator == 'not') { + $keys = &$query->getKeys(); if (is_array($keys)) { $keys['#negation'] = TRUE; } @@ -186,7 +207,7 @@ class SearchApiFulltext extends SearchApiFilterText { // If there were fulltext keys set, we take care to combine them in a // meaningful way (especially with negated keys). if ($old) { - $keys = &$this->query->getKeys(); + $keys = &$query->getKeys(); // Array-valued keys are combined. if (is_array($keys)) { // If the old keys weren't parsed into an array, we instead have to @@ -218,7 +239,7 @@ class SearchApiFulltext extends SearchApiFilterText { // update the originalKeys property. elseif (is_scalar($old_original)) { $combined_keys = "($old_original) ($keys)"; - $this->query->keys($combined_keys); + $query->keys($combined_keys); $keys = $combined_keys; } } @@ -233,6 +254,7 @@ class SearchApiFulltext extends SearchApiFilterText { */ protected function getFulltextFields() { $fields = array(); + /** @var \Drupal\search_api\IndexInterface $index */ $index = Index::load(substr($this->table, 17)); $fields_info = $index->getFields(); diff --git a/src/Plugin/views/filter/SearchApiLanguage.php b/src/Plugin/views/filter/SearchApiLanguage.php index 31eb689..b97031c 100644 --- a/src/Plugin/views/filter/SearchApiLanguage.php +++ b/src/Plugin/views/filter/SearchApiLanguage.php @@ -8,6 +8,8 @@ namespace Drupal\search_api\Plugin\views\filter; use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Defines a filter for filtering on the language of items. @@ -19,35 +21,86 @@ use Drupal\Core\Language\LanguageInterface; class SearchApiLanguage extends SearchApiFilterOptions { /** + * The language manager. + * + * @var \Drupal\Core\Language\LanguageManagerInterface|null + */ + protected $languageManager; + + /** * {@inheritdoc} */ - protected function getValueOptions() { - parent::getValueOptions(); + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + /** @var static $plugin */ + $plugin = parent::create($container, $configuration, $plugin_id, $plugin_definition); + + /** @var $language_manager \Drupal\Core\Language\LanguageManagerInterface */ + $language_manager = $container->get('language_manager'); + $plugin->setLanguageManager($language_manager); + + return $plugin; + } + + /** + * Retrieves the language manager. + * + * @return \Drupal\Core\Language\LanguageManagerInterface + * The language manager. + */ + public function getLanguageManager() { + return $this->languageManager ?: \Drupal::languageManager(); + } + + /** + * Sets the language manager. + * + * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager + * The new language manager. + * + * @return $this + */ + public function setLanguageManager(LanguageManagerInterface $language_manager) { + $this->languageManager = $language_manager; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getValueOptions() { + if (isset($this->valueOptions)) { + return $this->valueOptions; + } + $this->valueOptions = array( 'content' => $this->t('Current content language'), 'interface' => $this->t('Current interface language'), 'default' => $this->t('Default site language'), - ) + $this->valueOptions; + ); + + foreach ($this->getLanguageManager()->getLanguages(LanguageInterface::STATE_ALL) as $langcode => $language) { + $this->valueOptions[$langcode] = $language->getName(); + } + + return $this->valueOptions; } /** * {@inheritdoc} */ public function query() { - if (!is_array($this->value)) { - $this->value = $this->value ? array($this->value) : array(); - } - foreach ($this->value as $i => $v) { - if ($v == 'content') { - $this->value[$i] = \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId(); + foreach ($this->value as $i => $value) { + if ($value == 'content') { + $this->value[$i] = $this->getLanguageManager()->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId(); } - elseif ($v == 'interface') { - $this->value[$i] = \Drupal::languageManager()->getCurrentLanguage()->getId(); + elseif ($value == 'interface') { + $this->value[$i] = $this->getLanguageManager()->getCurrentLanguage()->getId(); } - elseif ($v == 'default') { - $this->value[$i] = \Drupal::languageManager()->getDefaultLanguage()->getId(); + elseif ($value == 'default') { + $this->value[$i] = $this->getLanguageManager()->getDefaultLanguage()->getId(); } } + parent::query(); } diff --git a/src/Plugin/views/filter/SearchApiTerm.php b/src/Plugin/views/filter/SearchApiTerm.php index 9841587..b30a14c 100644 --- a/src/Plugin/views/filter/SearchApiTerm.php +++ b/src/Plugin/views/filter/SearchApiTerm.php @@ -7,8 +7,7 @@ namespace Drupal\search_api\Plugin\views\filter; -use Drupal\Core\Form\FormStateInterface; -use Drupal\taxonomy\Entity\Vocabulary; +use Drupal\taxonomy\Plugin\views\filter\TaxonomyIndexTid; /** * Defines a filter for filtering on taxonomy term references. @@ -19,295 +18,8 @@ use Drupal\taxonomy\Entity\Vocabulary; * * @ViewsFilter("search_api_term") */ -// @todo Needs updating, especially the DB queries that merge on vocabulary. -class SearchApiTerm extends SearchApiFilterEntityBase { +class SearchApiTerm extends TaxonomyIndexTid { - /** - * {@inheritdoc} - */ - public function hasExtraOptions() { - return !empty($this->definition['vocabulary']); - } - - /** - * {@inheritdoc} - */ - public function defineOptions() { - $options = parent::defineOptions(); - - $options['type'] = array('default' => !empty($this->definition['vocabulary']) ? 'textfield' : 'select'); - $options['hierarchy'] = array('default' => 0); - $options['error_message'] = array('default' => TRUE, 'bool' => TRUE); - - return $options; - } - - /** - * {@inheritdoc} - */ - public function buildExtraOptionsForm(&$form, FormStateInterface $form_state) { - $form['type'] = array( - '#type' => 'radios', - '#title' => $this->t('Selection type'), - '#options' => array('select' => $this->t('Dropdown'), 'textfield' => $this->t('Autocomplete')), - '#default_value' => $this->options['type'], - ); - - $form['hierarchy'] = array( - '#type' => 'checkbox', - '#title' => $this->t('Show hierarchy in dropdown'), - '#default_value' => !empty($this->options['hierarchy']), - ); - $form['hierarchy']['#states']['visible'][':input[name="options[type]"]']['value'] = 'select'; - } - - /** - * {@inheritdoc} - */ - protected function valueForm(&$form, FormStateInterface $form_state) { - parent::valueForm($form, $form_state); - - if (!empty($this->definition['vocabulary'])) { - $vocabulary = Vocabulary::load($this->definition['vocabulary']); - $title = $this->t('Select terms from vocabulary @voc', array('@voc' => $vocabulary->label())); - } - else { - $vocabulary = FALSE; - $title = $this->t('Select terms'); - } - $form['value']['#title'] = $title; - - if ($vocabulary && $this->options['type'] == 'textfield') { - $form['value']['#autocomplete_path'] = 'admin/views/ajax/autocomplete/taxonomy/' . $vocabulary->id(); - } - else { - if ($vocabulary && !empty($this->options['hierarchy'])) { - /** @var \Drupal\taxonomy\TermStorageInterface $term_storage */ - $term_storage = \Drupal::entityManager()->getStorage('taxonomy_term'); - $tree = $term_storage->loadTree($vocabulary->id()); - $options = array(); - - if ($tree) { - foreach ($tree as $term) { - $choice = new \stdClass(); - $choice->option = array($term->tid => str_repeat('-', $term->depth) . $term->name); - $options[] = $choice; - } - } - } - else { - $options = array(); - $query = Database::getConnection()->select('taxonomy_term_data', 'td'); - $query->innerJoin('taxonomy_vocabulary', 'tv', 'td.vid = tv.vid'); - $query->fields('td'); - $query->orderby('tv.weight'); - $query->orderby('tv.name'); - $query->orderby('td.weight'); - $query->orderby('td.name'); - $query->addTag('term_access'); - if ($vocabulary) { - $query->condition('tv.vid', $vocabulary->id()); - } - $result = $query->execute(); - foreach ($result as $term) { - $options[$term->tid] = $term->name; - } - } - - $default_value = (array) $this->value; - - if ($form_state->get('exposed')) { - $identifier = $this->options['expose']['identifier']; - - if (!empty($this->options['expose']['reduce'])) { - $options = $this->reduceValueOptions($options); - - if (!empty($this->options['expose']['multiple']) && empty($this->options['expose']['required'])) { - $default_value = array(); - } - } - - if (empty($this->options['expose']['multiple'])) { - if (empty($this->options['expose']['required']) && (empty($default_value) || !empty($this->options['expose']['reduce']))) { - $default_value = 'All'; - } - elseif (empty($default_value)) { - $keys = array_keys($options); - $default_value = array_shift($keys); - } - // Due to #1464174 there is a chance that array('') was saved in the - // admin ui. Let's choose a safe default value. - elseif ($default_value == array('')) { - $default_value = 'All'; - } - else { - $copy = $default_value; - $default_value = array_shift($copy); - } - } - } - $form['value']['#type'] = 'select'; - $form['value']['#multiple'] = TRUE; - $form['value']['#options'] = $options; - $form['value']['#size'] = min(9, count($options)); - $form['value']['#default_value'] = $default_value; - - $input = &$form_state->getUserInput(); - if ($form_state->get('exposed') && isset($identifier) && !isset($input[$identifier])) { - $input[$identifier] = $default_value; - } - } - } - - /** - * Reduces the available exposed options according to the selection. - * - * @param array $options - * The original options list. - * - * @return array - * A reduced version of the options list. - */ - protected function reduceValueOptions(array $options) { - foreach ($options as $id => $option) { - if (empty($this->options['value'][$id])) { - unset($options[$id]); - } - } - return $options; - } - - /** - * {@inheritdoc} - */ - public function valueValidate($form, FormStateInterface $form_state) { - // We only validate if they've chosen the text field style. - if ($this->options['type'] != 'textfield') { - return; - } - - parent::valueValidate($form, $form_state); - } - - /** - * {@inheritdoc} - */ - public function acceptExposedInput($input) { - if (empty($this->options['exposed'])) { - return TRUE; - } - - // If view is an attachment and is inheriting exposed filters, then assume - // exposed input has already been validated. - if (!empty($this->view->is_attachment) && $this->view->display_handler->usesExposed()) { - $this->validatedExposedInput = (array) $this->view->exposed_raw_input[$this->options['expose']['identifier']]; - } - - // If it's non-required and there's no value don't bother filtering. - if (!$this->options['expose']['required'] && empty($this->validatedExposedInput)) { - return FALSE; - } - - return parent::acceptExposedInput($input); - } - - /** - * {@inheritdoc} - */ - public function validateExposed(&$form, FormStateInterface $form_state) { - if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) { - return; - } - - // We only validate if they've chosen the text field style. - if ($this->options['type'] != 'textfield') { - $input = $form_state->getValues()[$this->options['expose']['identifier']]; - if ($this->options['is_grouped'] && isset($this->options['group_info']['group_items'][$input])) { - $input = $this->options['group_info']['group_items'][$input]['value']; - } - - if ($input != 'All') { - $this->validatedExposedInput = (array) $input; - } - return; - } - - parent::validateExposed($form, $form_state); - } - - /** - * {@inheritdoc} - */ - protected function validateEntityStrings(array &$form, array $values, FormStateInterface $form_state) { - if (empty($values)) { - return array(); - } - - $tids = array(); - $names = array(); - $missing = array(); - foreach ($values as $value) { - $missing[strtolower($value)] = TRUE; - $names[] = $value; - } - - if (!$names) { - return array(); - } - - $query = Database::getConnection()->select('taxonomy_term_data', 'td'); - $query->innerJoin('taxonomy_vocabulary', 'tv', 'td.vid = tv.vid'); - $query->fields('td'); - $query->condition('td.name', $names); - if (!empty($this->definition['vocabulary'])) { - $query->condition('tv.id', $this->definition['vocabulary']); - } - $query->addTag('term_access'); - $result = $query->execute(); - foreach ($result as $term) { - unset($missing[strtolower($term->name)]); - $tids[] = $term->tid; - } - - if ($missing) { - if (!empty($this->options['error_message'])) { - $form_state->setError($form, $this->formatPlural(count($missing), 'Unable to find term: @terms', 'Unable to find terms: @terms', array('@terms' => implode(', ', array_keys($missing))))); - } - else { - // Add a bogus TID which will show an empty result for a positive filter - // and be ignored for an excluding one. - $tids[] = 0; - } - } - - return $tids; - } - - /** - * {@inheritdoc} - */ - public function buildExposeForm(&$form, FormStateInterface $form_state) { - parent::buildExposeForm($form, $form_state); - if ($this->options['type'] != 'select') { - unset($form['expose']['reduce']); - } - $form['error_message'] = array( - '#type' => 'checkbox', - '#title' => $this->t('Display error message'), - '#description' => $this->t('Display an error message if one of the entered terms could not be found.'), - '#default_value' => !empty($this->options['error_message']), - ); - } - - /** - * {@inheritdoc} - */ - protected function idsToString(array $ids) { - return implode(', ', Database::getConnection()->select('taxonomy_term_data', 'td') - ->fields('td', array('name')) - ->condition('td.tid', array_filter($ids)) - ->execute() - ->fetchCol()); - } + use SearchApiFilterTrait; } diff --git a/src/Plugin/views/filter/SearchApiUser.php b/src/Plugin/views/filter/SearchApiUser.php index 765c12f..8c77ed0 100644 --- a/src/Plugin/views/filter/SearchApiUser.php +++ b/src/Plugin/views/filter/SearchApiUser.php @@ -7,9 +7,7 @@ namespace Drupal\search_api\Plugin\views\filter; -use Drupal\Component\Utility\Unicode; -use Drupal\Core\Database\Database; -use Drupal\Core\Form\FormStateInterface; +use Drupal\user\Plugin\views\filter\Name; /** * Defines a filter for filtering on user references. @@ -20,68 +18,52 @@ use Drupal\Core\Form\FormStateInterface; * * @ViewsFilter("search_api_user") */ -class SearchApiUser extends SearchApiFilterEntityBase { +class SearchApiUser extends Name { - /** - * {@inheritdoc} - */ - protected function valueForm(&$form, FormStateInterface $form_state) { - parent::valueForm($form, $form_state); - - // Set autocompletion. - $path = $this->isMultiValued() ? 'admin/views/ajax/autocomplete/user' : 'user/autocomplete'; - $form['value']['#autocomplete_path'] = $path; - } - - /** - * {@inheritdoc} - */ - protected function idsToString(array $ids) { - $names = array(); - $args[':uids'] = array_filter($ids); - $result = Database::getConnection()->query("SELECT uid, name FROM {users} u WHERE uid IN (:uids)", $args); - $result = $result->fetchAllKeyed(); - foreach ($ids as $uid) { - if (!$uid) { - $names[] = \Drupal::config('user.settings')->get('anonymous'); - } - elseif (isset($result[$uid])) { - $names[] = $result[$uid]; - } - } - return implode(', ', $names); - } + use SearchApiFilterTrait; /** * {@inheritdoc} */ - protected function validateEntityStrings(array &$form, array $values, FormStateInterface $form_state) { - $uids = array(); - $missing = array(); - foreach ($values as $value) { - if (Unicode::strtolower($value) === Unicode::strtolower(\Drupal::config('user.settings')->get('anonymous'))) { - $uids[] = 0; - } - else { - $missing[strtolower($value)] = $value; - } - } - - if (!$missing) { - return $uids; - } - - $result = Database::getConnection()->query("SELECT * FROM {users} WHERE name IN (:names)", array(':names' => array_values($missing))); - foreach ($result as $account) { - unset($missing[strtolower($account->name)]); - $uids[] = $account->uid; - } - - if ($missing) { - $form_state->setError($form, $this->formatPlural(count($missing), 'Unable to find user: @users', 'Unable to find users: @users', array('@users' => implode(', ', $missing)))); - } - - return $uids; + function operators() { + return array( + 'or' => array( + 'title' => $this->t('Is one of'), + 'short' => $this->t('or'), + 'short_single' => $this->t('='), + 'method' => 'opHelper', + 'values' => 1, + 'ensure_my_table' => 'helper', + ), + 'and' => array( + 'title' => $this->t('Is all of'), + 'short' => $this->t('and'), + 'short_single' => $this->t('='), + 'method' => 'opHelper', + 'values' => 1, + 'ensure_my_table' => 'helper', + ), + 'not' => array( + 'title' => $this->t('Is none of'), + 'short' => $this->t('not'), + 'short_single' => $this->t('<>'), + 'method' => 'opHelper', + 'values' => 1, + 'ensure_my_table' => 'helper', + ), + 'empty' => array( + 'title' => $this->t('Is empty (NULL)'), + 'method' => 'opEmpty', + 'short' => $this->t('empty'), + 'values' => 0, + ), + 'not empty' => array( + 'title' => $this->t('Is not empty (NOT NULL)'), + 'method' => 'opEmpty', + 'short' => $this->t('not empty'), + 'values' => 0, + ), + ); } } diff --git a/src/Plugin/views/query/SearchApiEntityQuery.php b/src/Plugin/views/query/SearchApiEntityQuery.php new file mode 100644 index 0000000..eb9b3d5 --- /dev/null +++ b/src/Plugin/views/query/SearchApiEntityQuery.php @@ -0,0 +1,63 @@ +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..f6f6ab9 100644 --- a/src/Plugin/views/query/SearchApiQuery.php +++ b/src/Plugin/views/query/SearchApiQuery.php @@ -9,7 +9,8 @@ namespace Drupal\search_api\Plugin\views\query; use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Utility\Html; -use Drupal\Core\Entity\EntityManagerInterface; +use Drupal\Core\Database\Query\ConditionInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; use Drupal\search_api\Entity\Index; @@ -18,6 +19,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. @@ -97,7 +99,7 @@ class SearchApiQuery extends QueryPluginBase { * * @var array */ - protected $filters = array(); + protected $where = array(); /** * The conjunction with which multiple filter groups are combined. @@ -111,13 +113,16 @@ class SearchApiQuery extends QueryPluginBase { * * @param string $table * The Views base table ID. - * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_manager * (optional) The entity manager to use. * * @return \Drupal\search_api\IndexInterface|null * The requested search index, or NULL if it could not be found and loaded. */ - public static function getIndexFromTable($table, EntityManagerInterface $entity_manager = NULL) { + public static function getIndexFromTable($table, EntityTypeManagerInterface $entity_manager = NULL) { + // @todo Instead use Views::viewsData() – injected, too – to load the base + // table definition and use the "index" (or maybe rename to + // "search_api_index") field from there. if (substr($table, 0, 17) == 'search_api_index_') { $index_id = substr($table, 17); if ($entity_manager) { @@ -135,7 +140,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 +161,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; } /** @@ -260,7 +270,7 @@ class SearchApiQuery extends QueryPluginBase { } // Setup the nested filter structure for this query. - if (!empty($this->filters)) { + if (!empty($this->where)) { // If the different groups are combined with the OR operator, we have to // add a new OR filter to the query to which the filters for the groups // will be added. @@ -272,7 +282,7 @@ class SearchApiQuery extends QueryPluginBase { $base = $this->query; } // Add a nested filter for each filter group, with its set conjunction. - foreach ($this->filters as $group_id => $group) { + foreach ($this->where as $group_id => $group) { if (!empty($group['conditions']) || !empty($group['filters'])) { $group += array('type' => 'AND'); // For filters without a group, we want to always add them directly to @@ -373,8 +383,8 @@ class SearchApiQuery extends QueryPluginBase { // Store the results. if (!$skip_result_count) { $view->pager->total_items = $view->total_rows = $results->getResultCount(); - if (!empty($this->pager->options['offset'])) { - $this->pager->total_items -= $this->pager->options['offset']; + if (!empty($view->pager->options['offset'])) { + $view->pager->total_items -= $view->pager->options['offset']; } $view->pager->updatePageInfo(); } @@ -382,6 +392,7 @@ class SearchApiQuery extends QueryPluginBase { if ($results->getResultItems()) { $this->addResults($results->getResultItems(), $view); } + $view->execute_time = microtime(TRUE) - $start; // Trigger pager postExecute(). @@ -431,23 +442,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 +567,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; } /** @@ -611,16 +684,21 @@ class SearchApiQuery extends QueryPluginBase { * * @param \Drupal\search_api\Query\FilterInterface $filter * A filter that should be added as a subfilter. - * @param string|null $group + * @param int $group * (optional) The Views query filter group to add this filter to. * * @return $this * * @see \Drupal\search_api\Query\QueryInterface::filter() */ - public function filter(FilterInterface $filter, $group = NULL) { + public function filter(FilterInterface $filter, $group = 0) { if (!$this->shouldAbort()) { - $this->filters[$group]['filters'][] = $filter; + // Ensure all variants of 0 are actually 0. Thus '', 0 and NULL are all + // the default group. + if (empty($group)) { + $group = 0; + } + $this->where[$group]['filters'][] = $filter; } return $this; } @@ -642,21 +720,168 @@ class SearchApiQuery extends QueryPluginBase { * respectively. * If $value is NULL, $operator also can only be "=" or "<>", meaning the * field must have no or some value, respectively. - * @param string|null $group + * @param int $group * (optional) The Views query filter group to add this filter to. * * @return $this * * @see \Drupal\search_api\Query\QueryInterface::condition() */ - public function condition($field, $value, $operator = '=', $group = NULL) { + public function condition($field, $value, $operator = '=', $group = 0) { if (!$this->shouldAbort()) { - $this->filters[$group]['conditions'][] = array($field, $value, $operator); + // Ensure all variants of 0 are actually 0. Thus '', 0 and NULL are all + // the default group. + if (empty($group)) { + $group = 0; + } + $this->where[$group]['conditions'][] = array($field, $value, $operator); } return $this; } /** + * Adds a simple condition to the query. + * + * 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 filter() or condition() + * methods. + * + * @param int $group + * The condition group to add these to; groups are used to create AND/OR + * sections. Groups cannot be nested. Use 0 as the default group. + * If the group does not yet exist it will be created as an AND group. + * @param string|\Drupal\Core\Database\Query\ConditionInterface|\Drupal\search_api\Query\FilterInterface $field + * The ID of the field to check; or a filter object to add to the query; or, + * for compatibility purposes, a database condition object to transform into + * a search filter object and add to the query. If a field ID is passed and + * starts with a period (.), it will be stripped. + * @param mixed $value + * (optional) The value the field should have (or be related to by the + * operator). Or NULL if an object is passed as $field. + * @param string|null $operator + * (optional) The operator to use for checking the constraint. The following + * operators are supported for primitive types: "=", "<>", "<", "<=", ">=", + * ">". They have the same semantics as the corresponding SQL operators. + * If $field is a fulltext field, $operator can only be "=" or "<>", which + * are in this case interpreted as "contains" or "doesn't contain", + * respectively. + * If $value is NULL, $operator also can only be "=" or "<>", meaning the + * field must have no or some value, respectively. + * To stay compatible with Views, "!=" is supported as an alias for "<>". + * If an object is passed as $field, $operator should be NULL. + * + * @return $this + * + * @see \Drupal\views\Plugin\views\query\Sql::addWhere() + * @see \Drupal\search_api\Plugin\views\query\SearchApiQuery::filter() + * @see \Drupal\search_api\Plugin\views\query\SearchApiQuery::condition() + */ + public function addWhere($group, $field, $value = NULL, $operator = NULL) { + if ($this->shouldAbort()) { + return $this; + } + + // Ensure all variants of 0 are actually 0. Thus '', 0 and NULL are all the + // default group. + if (empty($group)) { + $group = 0; + } + + if (is_object($field)) { + if ($field instanceof ConditionInterface) { + $field = $this->transformConditionToFilter($field); + } + if ($field instanceof FilterInterface) { + $this->where[$group]['filters'][] = $field; + } + elseif (!$this->shouldAbort()) { + // We only need to abort if that wasn't done by + // transformConditionToFilter() already. + $this->abort('Unexpected condition passed to addWhere().'); + } + } + else { + $this->where[$group]['conditions'][] = array($this->sanitizeFieldId($field), $value, $this->sanitizeOperator($operator)); + } + + return $this; + } + + /** + * Transforms a database condition to an equivalent search filter. + * + * @param \Drupal\Core\Database\Query\ConditionInterface $db_condition + * The condition to transform. + * + * @return \Drupal\search_api\Query\FilterInterface|null + * A search filter equivalent to $condition, or NULL if the transformation + * failed. + */ + protected function transformConditionToFilter(ConditionInterface $db_condition) { + $conditions = $db_condition->conditions(); + $filter = $this->query->createFilter($conditions['#conjunction']); + unset($conditions['#conjunction']); + foreach ($conditions as $condition) { + if ($condition['operator'] === NULL) { + $this->abort('Trying to include a raw SQL condition in a Search API query.'); + return NULL; + } + if ($condition['field'] instanceof ConditionInterface) { + $nested_filter = $this->transformConditionToFilter($condition['field']); + if ($nested_filter) { + $filter->filter($nested_filter); + } + else { + return NULL; + } + } + else { + $filter->condition($this->sanitizeFieldId($condition['field']), $condition['value'], $this->sanitizeOperator($condition['operator'])); + } + } + return $filter; + } + + /** + * Adapts a field ID for use in a Search API query. + * + * This method will remove a leading period (.), if present. This is done + * because in the SQL Views query plugin field IDs are always prefixed with a + * table alias (in our case always empty) followed by a period. + * + * @param string $field_id + * The field ID to adapt for use in the Search API. + * + * @return string + * The sanitized field ID. + */ + protected function sanitizeFieldId($field_id) { + if ($field_id && $field_id[0] === '.') { + $field_id = substr($field_id, 1); + } + return $field_id; + } + + /** + * Adapts an operator for use in a Search API query. + * + * This method maps Views' "!=" to the "<>" Search API uses. + * + * @param string $operator + * The operator to adapt for use in the Search API. + * + * @return string + * The sanitized operator. + */ + protected function sanitizeOperator($operator) { + if ($operator === '!=') { + $operator = '<>'; + } + return $operator; + } + + /** * Adds a sort directive to this search query. * * If no sort is manually set, the results will be sorted descending by @@ -874,13 +1099,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 +1135,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) { diff --git a/src/Plugin/views/sort/SearchApiSort.php b/src/Plugin/views/sort/SearchApiSort.php index 0d8d815..3214517 100644 --- a/src/Plugin/views/sort/SearchApiSort.php +++ b/src/Plugin/views/sort/SearchApiSort.php @@ -12,7 +12,7 @@ use Drupal\views\Plugin\views\sort\SortPluginBase; /** * Provides a sort plugin for Search API views. * - * @ViewsSort("search_api_sort") + * @ViewsSort("search_api") */ class SearchApiSort extends SortPluginBase { diff --git a/src/Tests/Processor/ProcessorTestBase.php b/src/Tests/Processor/ProcessorTestBase.php index ec22ed9..e7d8fd8 100644 --- a/src/Tests/Processor/ProcessorTestBase.php +++ b/src/Tests/Processor/ProcessorTestBase.php @@ -101,8 +101,7 @@ abstract class ProcessorTestBase extends EntityUnitTestBase { ), )); - /** @var \Drupal\search_api\Processor\ProcessorPluginManager $plugin_manager */ - $plugin_manager = \Drupal::service('plugin.manager.search_api.processor'); + $plugin_manager = Utility::getProcessorPluginManager(); $this->processor = $plugin_manager->createInstance($processor, array('index' => $this->index)); } $this->index->save(); diff --git a/src/Utility.php b/src/Utility.php index b5f75c6..89524b3 100644 --- a/src/Utility.php +++ b/src/Utility.php @@ -158,7 +158,7 @@ class Utility { } static::$dataTypeFallbackMapping[$index_id] = array(); /** @var \Drupal\search_api\DataType\DataTypeInterface $data_type */ - foreach (\Drupal::service('plugin.manager.search_api.data_type')->getInstances() as $type_id => $data_type) { + foreach (self::getDataTypePluginManager()->getInstances() as $type_id => $data_type) { // We know for sure that we do not need to fall back for the default // data types as they are always present and are required to be // supported by all backends. @@ -561,4 +561,54 @@ class Utility { return array(substr($combined_id, 0, $pos), substr($combined_id, $pos + 1)); } + /** + * Retrieves the plugin manager for Search API backend plugins. + * + * @return \Drupal\search_api\Backend\BackendPluginManager + * The backend plugin manager. + */ + public static function getBackendPluginManager() { + return \Drupal::service('plugin.manager.search_api.backend'); + } + + /** + * Retrieves the plugin manager for Search API data type plugins. + * + * @return \Drupal\search_api\DataType\DataTypePluginManager + * The data type plugin manager. + */ + public static function getDataTypePluginManager() { + return \Drupal::service('plugin.manager.search_api.data_type'); + } + + /** + * Retrieves the plugin manager for Search API datasource plugins. + * + * @return \Drupal\search_api\Datasource\DatasourcePluginManager + * The datasource plugin manager. + */ + public static function getDatasourcePluginManager() { + return \Drupal::service('plugin.manager.search_api.datasource'); + } + + /** + * Retrieves the plugin manager for Search API processor plugins. + * + * @return \Drupal\search_api\Processor\ProcessorPluginManager + * The processor plugin manager. + */ + public static function getProcessorPluginManager() { + return \Drupal::service('plugin.manager.search_api.processor'); + } + + /** + * Retrieves the plugin manager for Search API tracker plugins. + * + * @return \Drupal\search_api\Tracker\TrackerPluginManager + * The tracker plugin manager. + */ + public static function getTrackerPluginManager() { + return \Drupal::service('plugin.manager.search_api.tracker'); + } + } 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 6f417a7..0031593 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 @@ -130,7 +130,7 @@ display: relationship: none group_type: group admin_label: '' - operator: AND + operator: and value: '' group: 1 exposed: true