search_api-8.x-1.15/src/Plugin/views/filter/SearchApiFulltext.php
src/Plugin/views/filter/SearchApiFulltext.php
<?php namespace Drupal\search_api\Plugin\views\filter; use Drupal\Core\Form\FormStateInterface; use Drupal\search_api\Entity\Index; use Drupal\search_api\ParseMode\ParseModePluginManager; use Drupal\views\Plugin\views\filter\FilterPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Defines a filter for adding a fulltext search to the view. * * @ingroup views_filter_handlers * * @ViewsFilter("search_api_fulltext") */ class SearchApiFulltext extends FilterPluginBase { use SearchApiFilterTrait; /** * The parse mode manager. * * @var \Drupal\search_api\ParseMode\ParseModePluginManager|null */ protected $parseModeManager; /** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { /** @var static $plugin */ $plugin = parent::create($container, $configuration, $plugin_id, $plugin_definition); $plugin->setParseModeManager($container->get('plugin.manager.search_api.parse_mode')); return $plugin; } /** * Retrieves the parse mode manager. * * @return \Drupal\search_api\ParseMode\ParseModePluginManager * The parse mode manager. */ public function getParseModeManager() { return $this->parseModeManager ?: \Drupal::service('plugin.manager.search_api.parse_mode'); } /** * Sets the parse mode manager. * * @param \Drupal\search_api\ParseMode\ParseModePluginManager $parse_mode_manager * The new parse mode manager. * * @return $this */ public function setParseModeManager(ParseModePluginManager $parse_mode_manager) { $this->parseModeManager = $parse_mode_manager; return $this; } /** * {@inheritdoc} */ public function showOperatorForm(&$form, FormStateInterface $form_state) { parent::showOperatorForm($form, $form_state); if (!empty($form['operator'])) { $form['operator']['#description'] = $this->t('Depending on the parse mode set, some of these options might not work as expected. Please either use "@multiple_words" as the parse mode or make sure that the filter behaves as expected for multiple words.', ['@multiple_words' => $this->t('Multiple words')]); } } /** * {@inheritdoc} */ public function operatorOptions($which = 'title') { $options = []; foreach ($this->operators() as $id => $info) { $options[$id] = $info[$which]; } return $options; } /** * Returns information about the available operators for this filter. * * @return array[] * An associative array mapping operator identifiers to their information. * The operator information itself is an associative array with the * following keys: * - title: The translated title for the operator. * - short: The translated short title for the operator. * - values: The number of values the operator requires as input. */ public function operators() { return [ 'and' => [ 'title' => $this->t('Contains all of these words'), 'short' => $this->t('and'), 'values' => 1, ], 'or' => [ 'title' => $this->t('Contains any of these words'), 'short' => $this->t('or'), 'values' => 1, ], 'not' => [ 'title' => $this->t('Contains none of these words'), 'short' => $this->t('not'), 'values' => 1, ], ]; } /** * {@inheritdoc} */ public function defineOptions() { $options = parent::defineOptions(); $options['operator']['default'] = 'and'; $options['parse_mode'] = ['default' => 'terms']; $options['min_length'] = ['default' => '']; $options['fields'] = ['default' => []]; $options['expose']['contains']['placeholder'] = ['default' => '']; return $options; } /** * {@inheritdoc} */ public function buildOptionsForm(&$form, FormStateInterface $form_state) { parent::buildOptionsForm($form, $form_state); $form['parse_mode'] = [ '#type' => 'select', '#title' => $this->t('Parse mode'), '#description' => $this->t('Choose how the search keys will be parsed.'), '#options' => [], '#default_value' => $this->options['parse_mode'], ]; foreach ($this->getParseModeManager()->getInstances() as $key => $mode) { if ($mode->isHidden()) { continue; } $form['parse_mode']['#options'][$key] = $mode->label(); if ($mode->getDescription()) { $states['visible'][':input[name="options[parse_mode]"]']['value'] = $key; $form["parse_mode_{$key}_description"] = [ '#type' => 'item', '#title' => $mode->label(), '#description' => $mode->getDescription(), '#states' => $states, ]; } } $fields = $this->getFulltextFields(); if (!empty($fields)) { $form['fields'] = [ '#type' => 'select', '#title' => $this->t('Searched fields'), '#description' => $this->t('Select the fields that will be searched. If no fields are selected, all available fulltext fields will be searched.'), '#options' => $fields, '#size' => min(4, count($fields)), '#multiple' => TRUE, '#default_value' => $this->options['fields'], ]; } else { $form['fields'] = [ '#type' => 'value', '#value' => [], ]; } if (isset($form['expose'])) { $form['expose']['#weight'] = -5; } $form['min_length'] = [ '#title' => $this->t('Minimum keyword length'), '#description' => $this->t('Minimum length of each word in the search keys. Leave empty to allow all words.'), '#type' => 'number', '#min' => 1, '#default_value' => $this->options['min_length'], ]; } /** * {@inheritdoc} */ public function buildExposeForm(&$form, FormStateInterface $form_state) { parent::buildExposeForm($form, $form_state); $form['expose']['placeholder'] = [ '#type' => 'textfield', '#default_value' => $this->options['expose']['placeholder'], '#title' => $this->t('Placeholder'), '#size' => 40, '#description' => $this->t('Hint text that appears inside the field when empty.'), ]; } /** * {@inheritdoc} */ protected function valueForm(&$form, FormStateInterface $form_state) { parent::valueForm($form, $form_state); $form['value'] = [ '#type' => 'textfield', '#title' => !$form_state->get('exposed') ? $this->t('Value') : '', '#size' => 30, '#default_value' => $this->value, ]; if (!empty($this->options['expose']['placeholder'])) { $form['value']['#attributes']['placeholder'] = $this->options['expose']['placeholder']; } } /** * {@inheritdoc} */ protected function exposedTranslate(&$form, $type) { parent::exposedTranslate($form, $type); // We use custom validation for "required", so don't want the Form API to // interfere. // @see ::validateExposed() $form['#required'] = FALSE; } /** * {@inheritdoc} */ public function validateExposed(&$form, FormStateInterface $form_state) { // Only validate exposed input. if (empty($this->options['exposed']) || empty($this->options['expose']['identifier'])) { return; } $identifier = $this->options['expose']['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']; $input = &$this->options['group_info']['group_items'][$input]['value']; } // Under some circumstances, input will be an array containing the string // value. Not sure why, but just deal with that. while (is_array($input)) { $input = $input ? reset($input) : ''; } if (trim($input) === '') { // No input was given by the user. If the filter was set to "required" and // there is a query (not the case when an exposed filter block is // displayed stand-alone), abort it. if (!empty($this->options['expose']['required']) && $this->getQuery()) { $this->getQuery()->abort(); } // If the input is empty, there is nothing to validate: return early. return; } // Only continue if there is a minimum word length set. if ($this->options['min_length'] < 2) { return; } $words = preg_split('/\s+/', $input); foreach ($words as $i => $word) { if (mb_strlen($word) < $this->options['min_length']) { unset($words[$i]); } } if (!$words) { $vars['@count'] = $this->options['min_length']; $msg = $this->t('You must include at least one positive keyword with @count characters or more.', $vars); $form_state->setError($form[$identifier], $msg); } $input = implode(' ', $words); } /** * {@inheritdoc} */ public function query() { while (is_array($this->value)) { $this->value = $this->value ? reset($this->value) : ''; } // Catch empty strings entered by the user, but not "0". if ($this->value === '') { return; } $fields = $this->options['fields']; $fields = $fields ?: array_keys($this->getFulltextFields()); $query = $this->getQuery(); // Save any keywords that were already set. $old = $query->getKeys(); $old_original = $query->getOriginalKeys(); if ($this->options['parse_mode']) { /** @var \Drupal\search_api\ParseMode\ParseModeInterface $parse_mode */ $parse_mode = $this->getParseModeManager() ->createInstance($this->options['parse_mode']); $query->setParseMode($parse_mode); } // If something already specifically set different fields, we silently fall // back to mere filtering. $old_fields = $query->getFulltextFields(); $use_conditions = $old_fields && (array_diff($old_fields, $fields) || array_diff($fields, $old_fields)); if ($use_conditions) { $conditions = $query->createConditionGroup('OR'); $op = $this->operator === 'not' ? '<>' : '='; foreach ($fields as $field) { $conditions->addCondition($field, $this->value, $op); } $query->addConditionGroup($conditions); 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') { $query->getParseMode()->setConjunction('OR'); } $query->setFulltextFields($fields); $query->keys($this->value); if ($this->operator == 'not') { $keys = &$query->getKeys(); if (is_array($keys)) { $keys['#negation'] = TRUE; } else { // We can't know how negation is expressed in the server's syntax. } unset($keys); } // If there were fulltext keys set, we take care to combine them in a // meaningful way (especially with negated keys). if ($old) { $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 // combine the original keys. if (is_scalar($old)) { $keys = "($old) ({$this->value})"; } else { // If the conjunction or negation settings aren't the same, we have to // nest both old and new keys array. if (empty($keys['#negation']) !== empty($old['#negation']) || $keys['#conjunction'] !== $old['#conjunction']) { $keys = [ '#conjunction' => 'AND', $old, $keys, ]; } // Otherwise, just add all individual words from the old keys to the // new ones. else { foreach ($old as $key => $value) { if (substr($key, 0, 1) === '#') { continue; } $keys[] = $value; } } } } // If the parse mode was "direct" for both old and new keys, we // concatenate them and set them both via method and reference (to also // update the originalKeys property. elseif (is_scalar($old_original)) { $combined_keys = "($old_original) ($keys)"; $query->keys($combined_keys); $keys = $combined_keys; } unset($keys); } } /** * Retrieves a list of all available fulltext fields. * * @return string[] * An options list of fulltext field identifiers mapped to their prefixed * labels. */ protected function getFulltextFields() { $fields = []; /** @var \Drupal\search_api\IndexInterface $index */ $index = Index::load(substr($this->table, 17)); $fields_info = $index->getFields(); foreach ($index->getFulltextFields() as $field_id) { $fields[$field_id] = $fields_info[$field_id]->getPrefixedLabel(); } return $fields; } }