You are here

protected function Database::createKeysQuery in Search API 8

Creates a SELECT query for given search keys.

Used as a helper method in createDbQuery() and createDbCondition().

Parameters

string|array $keys: The search keys, formatted like the return value of \Drupal\search_api\ParseMode\ParseModeInterface::parseInput(), but preprocessed according to internal requirements.

array $fields: The fulltext fields on which to search, with their names as keys mapped to internal information about them.

array $all_fields: Internal information about all indexed fields on the index.

\Drupal\search_api\IndexInterface $index: The index we're searching on.

Return value

\Drupal\Core\Database\Query\SelectInterface A SELECT query returning item_id and score (or only item_id, if $keys['#negation'] is set).

2 calls to Database::createKeysQuery()
Database::createDbCondition in modules/search_api_db/src/Plugin/search_api/backend/Database.php
Creates a database query condition for a given search filter.
Database::createDbQuery in modules/search_api_db/src/Plugin/search_api/backend/Database.php
Creates a database query for a search.

File

modules/search_api_db/src/Plugin/search_api/backend/Database.php, line 1962

Class

Database
Indexes and searches items using the database.

Namespace

Drupal\search_api_db\Plugin\search_api\backend

Code

protected function createKeysQuery($keys, array $fields, array $all_fields, IndexInterface $index) {
  if (!is_array($keys)) {
    $keys = [
      '#conjunction' => 'AND',
      $keys,
    ];
  }
  $neg = !empty($keys['#negation']);
  $conj = $keys['#conjunction'];
  $words = [];
  $nested = [];
  $negated = [];
  $db_query = NULL;
  $mul_words = FALSE;
  $neg_nested = $neg && $conj == 'AND';
  $match_parts = $this->configuration['matching'] !== 'words';
  $keyword_hits = [];
  $prefix_search = $this->configuration['matching'] === 'prefix';
  foreach ($keys as $i => $key) {
    if (!Element::child($i)) {
      continue;
    }
    if (is_scalar($key)) {
      $words[] = $key;
    }
    elseif (empty($key['#negation'])) {
      if ($neg) {

        // If this query is negated, we also only need item IDs from
        // subqueries.
        $key['#negation'] = TRUE;
      }
      $nested[] = $key;
    }
    else {
      $negated[] = $key;
    }
  }
  $word_count = count($words);
  $subs = $word_count + count($nested);
  $not_nested = $subs <= 1 && count($fields) == 1 || $neg && $conj == 'OR' && !$negated;
  if ($words) {

    // All text fields in the index share a table. Get name from the first.
    $field = reset($fields);
    $db_query = $this->database
      ->select($field['table'], 't');
    $mul_words = $word_count > 1;
    if ($neg_nested) {
      $db_query
        ->fields('t', [
        'item_id',
        'word',
      ]);
    }
    elseif ($neg) {
      $db_query
        ->fields('t', [
        'item_id',
      ]);
    }
    elseif ($not_nested && $match_parts) {
      $db_query
        ->fields('t', [
        'item_id',
      ]);
      $db_query
        ->addExpression('SUM(t.score)', 'score');
    }
    elseif ($not_nested || $match_parts) {
      $db_query
        ->fields('t', [
        'item_id',
        'score',
      ]);
    }
    else {
      $db_query
        ->fields('t', [
        'item_id',
        'score',
        'word',
      ]);
    }
    if (!$match_parts) {
      $db_query
        ->condition('t.word', $words, 'IN');
    }
    else {
      $db_or = $db_query
        ->orConditionGroup();

      // GROUP BY all existing non-aggregated columns.
      foreach ($db_query
        ->getFields() as $column) {
        $db_query
          ->groupBy("{$column['table']}.{$column['field']}");
      }
      foreach ($words as $i => $word) {
        $like = $this->database
          ->escapeLike($word);
        $like = $prefix_search ? "{$like}%" : "%{$like}%";
        $db_or
          ->condition('t.word', $like, 'LIKE');

        // Add an expression for each keyword that shows whether the indexed
        // word matches that particular keyword. That way we don't return a
        // result multiple times if a single indexed word (partially) matches
        // multiple keywords. We also remember the column name so we can
        // afterwards verify that each word matched at least once.
        $alias = 'w' . ++$this->expressionCounter;
        $like = '%' . $this->database
          ->escapeLike($word) . '%';
        $alias = $db_query
          ->addExpression("CASE WHEN t.word LIKE :like_{$alias} THEN 1 ELSE 0 END", $alias, [
          ":like_{$alias}" => $like,
        ]);
        $db_query
          ->groupBy($alias);
        $keyword_hits[] = $alias;
      }

      // Also add expressions for any nested queries.
      for ($i = $word_count; $i < $subs; ++$i) {
        $alias = 'w' . ++$this->expressionCounter;
        $alias = $db_query
          ->addExpression('0', $alias);
        $db_query
          ->groupBy($alias);
        $keyword_hits[] = $alias;
      }
      $db_query
        ->condition($db_or);
    }
    $field_names = array_keys($fields);
    $field_names = array_map([
      __CLASS__,
      'getTextFieldName',
    ], $field_names);
    $db_query
      ->condition('t.field_name', $field_names, 'IN');
  }
  if ($nested) {
    $word = '';
    foreach ($nested as $i => $k) {
      $query = $this
        ->createKeysQuery($k, $fields, $all_fields, $index);
      if (!$neg) {
        if (!$match_parts) {
          $word .= ' ';
          $var = ':word' . strlen($word);
          $query
            ->addExpression($var, 't.word', [
            $var => $word,
          ]);
        }
        else {
          $i += $word_count;
          for ($j = 0; $j < $subs; ++$j) {
            $alias = $keyword_hits[$j] ?? "w{$j}";
            $keyword_hits[$j] = $query
              ->addExpression($i == $j ? '1' : '0', $alias);
          }
        }
      }
      if (!isset($db_query)) {
        $db_query = $query;
      }
      elseif ($not_nested) {
        $db_query
          ->union($query, 'UNION');
      }
      else {
        $db_query
          ->union($query, 'UNION ALL');
      }
    }
  }
  if (isset($db_query) && !$not_nested) {
    $db_query = $this->database
      ->select($db_query, 't');
    $db_query
      ->addField('t', 'item_id', 'item_id');
    if (!$neg) {
      $db_query
        ->addExpression('SUM(t.score)', 'score');
      $db_query
        ->groupBy('t.item_id');
    }
    if ($conj == 'AND' && $subs > 1) {
      $var = ':subs' . (int) $subs;
      if (!$db_query
        ->getGroupBy()) {
        $db_query
          ->groupBy('t.item_id');
      }
      if (!$match_parts) {
        if ($mul_words) {
          $db_query
            ->having('COUNT(DISTINCT t.word) >= ' . $var, [
            $var => $subs,
          ]);
        }
        else {
          $db_query
            ->having('COUNT(t.word) >= ' . $var, [
            $var => $subs,
          ]);
        }
      }
      else {
        foreach ($keyword_hits as $alias) {
          $db_query
            ->having("SUM({$alias}) >= 1");
        }
      }
    }
  }
  if ($negated) {
    if (!isset($db_query) || $conj == 'OR') {
      if (isset($db_query)) {

        // We are in a rather bizarre case where the keys are something like
        // "a OR (NOT b)".
        $old_query = $db_query;
      }

      // We use this table because all items should be contained exactly once.
      $db_info = $this
        ->getIndexDbInfo($index);
      $db_query = $this->database
        ->select($db_info['index_table'], 't');
      $db_query
        ->addField('t', 'item_id', 'item_id');
      if (!$neg) {
        $db_query
          ->addExpression(':score', 'score', [
          ':score' => self::SCORE_MULTIPLIER,
        ]);
        $db_query
          ->distinct();
      }
    }
    if ($conj == 'AND') {
      $condition = $db_query;
    }
    else {
      $condition = $db_query
        ->conditionGroupFactory('OR');
      $db_query
        ->condition($condition);
    }
    foreach ($negated as $k) {
      $nested_query = $this
        ->createKeysQuery($k, $fields, $all_fields, $index);

      // For a "NOT IN", the SELECT must not have more than one column.
      $num_fields = count($nested_query
        ->getFields());
      $num_expressions = count($nested_query
        ->getExpressions());
      if ($num_fields + $num_expressions > 1) {
        $nested_query = $this->database
          ->select($nested_query, 't')
          ->fields('t', [
          'item_id',
        ]);
      }
      $condition
        ->condition('t.item_id', $nested_query, 'NOT IN');
    }
    if (isset($old_query)) {
      $condition
        ->condition('t.item_id', $old_query, 'NOT IN');
    }
  }
  if ($neg_nested) {
    $db_query = $this->database
      ->select($db_query, 't')
      ->fields('t', [
      'item_id',
    ]);
  }
  return $db_query;
}