1198 lines
40 KiB
PHP
1198 lines
40 KiB
PHP
<?php
|
||
|
||
/**
|
||
* Search service class using the database for storing index information.
|
||
*/
|
||
class SearchApiDbService extends SearchApiAbstractService {
|
||
|
||
protected $previous_db;
|
||
protected $query_options = array();
|
||
protected $ignored = array();
|
||
protected $warnings = array();
|
||
|
||
public function configurationForm(array $form, array &$form_state) {
|
||
if (empty($this->options)) {
|
||
global $databases;
|
||
foreach ($databases as $key => $targets) {
|
||
foreach ($targets as $target => $info) {
|
||
$options[$key]["$key:$target"] = "$key > $target";
|
||
}
|
||
}
|
||
if (count($options) > 1 || count(reset($options)) > 1) {
|
||
$form['database'] = array(
|
||
'#type' => 'select',
|
||
'#title' => t('Database'),
|
||
'#description' => t('Select the database key and target to use for storing indexing information in. ' .
|
||
'Cannot be changed after creation.'),
|
||
'#options' => $options,
|
||
'#default_value' => 'default:default',
|
||
'#required' => TRUE,
|
||
);
|
||
}
|
||
else {
|
||
$form['database'] = array(
|
||
'#type' => 'value',
|
||
'#value' => "$key:$target",
|
||
);
|
||
}
|
||
$form['min_chars'] = array(
|
||
'#type' => 'select',
|
||
'#title' => t('Minimum word length'),
|
||
'#description' => t('The minimum number of characters a word must consist of to be indexed.'),
|
||
'#options' => drupal_map_assoc(array(1, 2, 3, 4, 5, 6)),
|
||
'#default_value' => 1,
|
||
);
|
||
}
|
||
else {
|
||
$form = array(
|
||
'database' => array(
|
||
'#type' => 'value',
|
||
'#title' => t('Database'), // Slight hack for the "View server" page.
|
||
'#value' => $this->options['database'],
|
||
),
|
||
'database_text' => array(
|
||
'#type' => 'item',
|
||
'#title' => t('Database'),
|
||
'#markup' => check_plain(str_replace(':', ' > ', $this->options['database'])),
|
||
),
|
||
'min_chars' => array(
|
||
'#type' => 'select',
|
||
'#title' => t('Minimum word length'),
|
||
'#description' => t('The minimum number of characters a word must consist of to be indexed.'),
|
||
'#options' => drupal_map_assoc(array(1, 2, 3, 4, 5, 6)),
|
||
'#default_value' => $this->options['min_chars'],
|
||
),
|
||
);
|
||
}
|
||
|
||
return $form;
|
||
}
|
||
|
||
public function supportsFeature($feature) {
|
||
return $feature == 'search_api_facets';
|
||
}
|
||
|
||
public function postUpdate() {
|
||
return $this->server->options != $this->server->original->options;
|
||
}
|
||
|
||
public function preDelete() {
|
||
if (empty($this->options['indexes'])) {
|
||
return;
|
||
}
|
||
foreach ($this->options['indexes'] as $index) {
|
||
foreach ($index as $field) {
|
||
db_drop_table($field['table']);
|
||
}
|
||
}
|
||
}
|
||
|
||
public function addIndex(SearchApiIndex $index) {
|
||
$this->options += array('indexes' => array());
|
||
$indexes = &$this->options['indexes'];
|
||
if (isset($indexes[$index->machine_name])) {
|
||
// Easiest and safest method to ensure all of the index' data is properly re-added.
|
||
$this->removeIndex($index);
|
||
}
|
||
if (empty($index->options['fields']) || !is_array($index->options['fields'])) {
|
||
// No fields, no work.
|
||
$indexes[$index->machine_name] = array();
|
||
$this->server->save();
|
||
return $this;
|
||
}
|
||
|
||
$prefix = 'search_api_db_' . $index->machine_name. '_';
|
||
$indexes[$index->machine_name] = array();
|
||
foreach ($index->getFields() as $name => $field) {
|
||
$table = $this->findFreeTable($prefix, $name);
|
||
$this->createFieldTable($index, $field, $table);
|
||
$indexes[$index->machine_name][$name]['table'] = $table;
|
||
$indexes[$index->machine_name][$name]['type'] = $field['type'];
|
||
$indexes[$index->machine_name][$name]['boost'] = $field['boost'];
|
||
}
|
||
|
||
$this->server->save();
|
||
}
|
||
|
||
/**
|
||
* Helper method for finding free table names for fields.
|
||
*
|
||
* MySQL 5.0 imposes a 64 characters length limit for table names, PostgreSQL
|
||
* 8.3 only allows 63 characters. Therefore, always return a name at most 63
|
||
* characters long.
|
||
*/
|
||
protected function findFreeTable($prefix, $name) {
|
||
// A DB prefix might further reduce the maximum length of the table name.
|
||
$maxlen = 63;
|
||
list($key, $target) = explode(':', $this->options['database'], 2);
|
||
if ($db_prefix = Database::getConnection($target, $key)->tablePrefix()) {
|
||
$maxlen -= drupal_strlen($db_prefix);
|
||
}
|
||
|
||
$base = $table = drupal_substr($prefix . drupal_strtolower(preg_replace('/[^a-z0-9]/i', '_', $name)), 0, $maxlen);
|
||
$i = 0;
|
||
while (db_table_exists($table)) {
|
||
$suffix = '_' . ++$i;
|
||
$table = drupal_substr($base, 0, $maxlen - drupal_strlen($suffix)) . $suffix;
|
||
}
|
||
return $table;
|
||
}
|
||
|
||
/**
|
||
* Helper method for creating the table for a field.
|
||
*/
|
||
protected function createFieldTable(SearchApiIndex $index, $field, $name) {
|
||
$table = array(
|
||
'name' => $name,
|
||
'module' => 'search_api_db',
|
||
'fields' => array(
|
||
'item_id' => array(
|
||
'description' => 'The primary identifier of the item.',
|
||
'not null' => TRUE,
|
||
),
|
||
),
|
||
);
|
||
// The type of the item_id field depends on the ID field's type.
|
||
$id_field = $index->datasource()->getIdFieldInfo();
|
||
$table['fields']['item_id'] += $this->sqlType($id_field['type'] == 'text' ? 'string' : $id_field['type']);
|
||
if (isset($table['fields']['item_id']['length'])) {
|
||
// A length of 255 is overkill for IDs. 50 should be more than enough.
|
||
$table['fields']['item_id']['length'] = 50;
|
||
}
|
||
|
||
$type = search_api_extract_inner_type($field['type']);
|
||
if ($type == 'text') {
|
||
$table['fields']['word'] = array(
|
||
'description' => 'The text of the indexed token.',
|
||
'type' => 'varchar',
|
||
'length' => 50,
|
||
'not null' => TRUE,
|
||
);
|
||
$table['fields']['score'] = array(
|
||
'description' => 'The score associated with this token.',
|
||
'type' => 'float',
|
||
'not null' => TRUE,
|
||
);
|
||
$table['primary key'] = array('item_id', 'word');
|
||
$table['indexes']['word'] = array(array('word', 10));
|
||
}
|
||
else {
|
||
$table['fields']['value'] = $this->sqlType($type);
|
||
$table['fields']['value'] += array('description' => "The field's value for this item.");
|
||
if ($type != $field['type']) {
|
||
// This is a list type.
|
||
$table['fields']['value']['not null'] = TRUE;
|
||
$table['primary key'] = array('item_id', 'value');
|
||
}
|
||
else {
|
||
$table['primary key'] = array('item_id');
|
||
}
|
||
$table['indexes']['value'] = $table['fields']['value'] == 'varchar' ? array(array('value', 10)) : array('value');
|
||
}
|
||
|
||
$set = $this->setDb();
|
||
db_create_table($name, $table);
|
||
if ($set) {
|
||
$this->resetDb();
|
||
}
|
||
}
|
||
|
||
protected function sqlType($type) {
|
||
$type = search_api_extract_inner_type($type);
|
||
switch ($type) {
|
||
case 'string':
|
||
case 'uri':
|
||
return array('type' => 'varchar', 'length' => 255);
|
||
case 'integer':
|
||
case 'duration':
|
||
case 'date': // 'datetime' sucks. This way, we just convert every input into a timestamp.
|
||
return array('type' => 'int');
|
||
case 'decimal':
|
||
return array('type' => 'float');
|
||
case 'boolean':
|
||
return array('type' => 'int', 'size' => 'tiny');
|
||
|
||
default:
|
||
throw new SearchApiException(t('Unknown field type @type. Database search module might be out of sync with Search API.', array('@type' => $type)));
|
||
}
|
||
}
|
||
|
||
public function fieldsUpdated(SearchApiIndex $index) {
|
||
$fields = &$this->options['indexes'][$index->machine_name];
|
||
$new_fields = $index->getFields();
|
||
|
||
$reindex = FALSE;
|
||
$cleared = FALSE;
|
||
$set = $this->setDb();
|
||
foreach ($fields as $name => $field) {
|
||
if (!isset($new_fields[$name])) {
|
||
db_drop_table($field['table']);
|
||
unset($fields[$name]);
|
||
continue;
|
||
}
|
||
$old_type = $field['type'];
|
||
$new_type = $new_fields[$name]['type'];
|
||
$fields[$name]['type'] = $new_type;
|
||
$fields[$name]['boost'] = $new_fields[$name]['boost'];
|
||
$old_inner_type = search_api_extract_inner_type($old_type);
|
||
$new_inner_type = search_api_extract_inner_type($new_type);
|
||
if ($old_type != $new_type) {
|
||
if ($old_inner_type == 'text' || $new_inner_type == 'text'
|
||
|| search_api_list_nesting_level($old_type) != search_api_list_nesting_level($new_type)) {
|
||
// A change in fulltext or list status necessitates completely
|
||
// clearing the index.
|
||
$reindex = TRUE;
|
||
if (!$cleared) {
|
||
$cleared = TRUE;
|
||
$this->deleteItems('all', $index);
|
||
}
|
||
db_drop_table($field['table']);
|
||
$this->createFieldTable($index, $new_fields[$name], $field['table']);
|
||
}
|
||
elseif ($this->sqlType($old_inner_type) != $this->sqlType($new_inner_type)) {
|
||
// There is a change in SQL type. We don't have to clear the index, since types can be converted.
|
||
db_change_field($field['table'], 'value', 'value', $this->sqlType($new_type) + array('description' => "The field's value for this item."));
|
||
$reindex = TRUE;
|
||
}
|
||
elseif ($old_inner_type == 'date' || $new_inner_type == 'date') {
|
||
// Even though the SQL type stays the same, we have to reindex since conversion rules change.
|
||
$reindex = TRUE;
|
||
}
|
||
}
|
||
elseif (!$reindex && $new_inner_type == 'text' && $field['boost'] != $new_fields[$name]['boost']) {
|
||
$multiplier = $new_fields[$name]['boost'] / $field['boost'];
|
||
db_update($field['table'], $this->query_options)
|
||
->expression('score', 'score * :mult', array(':mult' => $multiplier))
|
||
->execute();
|
||
}
|
||
unset($new_fields[$name]);
|
||
}
|
||
$prefix = 'search_api_db_' . $index->machine_name. '_';
|
||
// These are new fields that were previously not indexed.
|
||
foreach ($new_fields as $name => $field) {
|
||
$reindex = TRUE;
|
||
$table = $this->findFreeTable($prefix, $name);
|
||
$this->createFieldTable($index, $field, $table);
|
||
$fields[$name]['table'] = $table;
|
||
$fields[$name]['type'] = $field['type'];
|
||
$fields[$name]['boost'] = $field['boost'];
|
||
}
|
||
if ($set) {
|
||
$this->resetDb();
|
||
}
|
||
|
||
$this->server->save();
|
||
return $reindex;
|
||
}
|
||
|
||
public function removeIndex($index) {
|
||
$id = is_object($index) ? $index->machine_name : $index;
|
||
if (!isset($this->options['indexes'][$id])) {
|
||
return;
|
||
}
|
||
$set = $this->setDb();
|
||
foreach ($this->options['indexes'][$id] as $field) {
|
||
db_drop_table($field['table']);
|
||
}
|
||
if ($set) {
|
||
$this->resetDb();
|
||
}
|
||
unset($this->options['indexes'][$id]);
|
||
$this->server->save();
|
||
}
|
||
|
||
public function indexItems(SearchApiIndex $index, array $items) {
|
||
if (empty($this->options['indexes'][$index->machine_name])) {
|
||
throw new SearchApiException(t('No field settings for index with id @id.', array('@id' => $index->machine_name)));
|
||
}
|
||
$indexed = array();
|
||
$set = $this->setDb();
|
||
foreach ($items as $id => $item) {
|
||
try {
|
||
if ($this->indexItem($index, $id, $item)) {
|
||
$indexed[] = $id;
|
||
}
|
||
}
|
||
catch (Exception $e) {
|
||
// We just log the error, hoping we can index the other items.
|
||
watchdog('search_api_db', check_plain($e->getMessage()), NULL, WATCHDOG_WARNING);
|
||
}
|
||
}
|
||
if ($set) {
|
||
$this->resetDb();
|
||
}
|
||
return $indexed;
|
||
}
|
||
|
||
protected function indexItem(SearchApiIndex $index, $id, array $item) {
|
||
$fields = $this->options['indexes'][$index->machine_name];
|
||
$txn = db_transaction('search_api_indexing', $this->query_options);
|
||
try {
|
||
foreach ($item as $name => $field) {
|
||
$table = $fields[$name]['table'];
|
||
$boost = $fields[$name]['boost'];
|
||
db_delete($table, $this->query_options)
|
||
->condition('item_id', $id)
|
||
->execute();
|
||
// Don't index null values
|
||
if($field['value'] === NULL) {
|
||
continue;
|
||
}
|
||
$type = $field['type'];
|
||
$value = $this->convert($field['value'], $type, $field['original_type'], $index);
|
||
|
||
if (search_api_is_text_type($type, array('text', 'tokens'))) {
|
||
$words = array();
|
||
foreach ($value as $token) {
|
||
// Taken from core search to reflect less importance of words later
|
||
// in the text.
|
||
// Focus is a decaying value in terms of the amount of unique words
|
||
// up to this point. From 100 words and more, it decays, to e.g. 0.5
|
||
// at 500 words and 0.3 at 1000 words.
|
||
$focus = min(1, .01 + 3.5 / (2 + count($words) * .015));
|
||
|
||
$value = &$token['value'];
|
||
if (is_numeric($value)) {
|
||
$value = ltrim($value, '-0');
|
||
}
|
||
elseif (drupal_strlen($value) < $this->options['min_chars']) {
|
||
continue;
|
||
}
|
||
$value = drupal_strtolower($value);
|
||
$token['score'] *= $focus;
|
||
if (!isset($words[$value])) {
|
||
$words[$value] = $token;
|
||
}
|
||
else {
|
||
$words[$value]['score'] += $token['score'];
|
||
}
|
||
}
|
||
if ($words) {
|
||
$query = db_insert($table, $this->query_options)
|
||
->fields(array('item_id', 'word', 'score'));
|
||
foreach ($words as $word) {
|
||
$query->values(array(
|
||
'item_id' => $id,
|
||
'word' => $word['value'],
|
||
'score' => $word['score'] * $boost,
|
||
));
|
||
}
|
||
$query->execute();
|
||
}
|
||
}
|
||
elseif (search_api_is_list_type($type)) {
|
||
$values = array();
|
||
if (is_array($value)) {
|
||
foreach ($value as $v) {
|
||
if ($v !== NULL) {
|
||
$values[$v] = TRUE;
|
||
}
|
||
}
|
||
$values = array_keys($values);
|
||
}
|
||
else {
|
||
$values[] = $value;
|
||
}
|
||
if ($values) {
|
||
$insert = db_insert($table, $this->query_options)
|
||
->fields(array('item_id', 'value'));
|
||
foreach ($values as $v) {
|
||
$insert->values(array(
|
||
'item_id' => $id,
|
||
'value' => $v,
|
||
));
|
||
}
|
||
$insert->execute();
|
||
}
|
||
}
|
||
elseif (isset($value)) {
|
||
db_insert($table, $this->query_options)
|
||
->fields(array(
|
||
'item_id' => $id,
|
||
'value' => $value,
|
||
))
|
||
->execute();
|
||
}
|
||
}
|
||
}
|
||
catch (Exception $e) {
|
||
$txn->rollback();
|
||
throw $e;
|
||
}
|
||
return TRUE;
|
||
}
|
||
|
||
protected function convert($value, $type, $original_type, SearchApiIndex $index) {
|
||
if (search_api_is_list_type($type)) {
|
||
$type = substr($type, 5, -1);
|
||
$original_type = search_api_extract_inner_type($original_type);
|
||
$ret = array();
|
||
if (is_array($value)) {
|
||
foreach ($value as $v) {
|
||
$v = $this->convert($v, $type, $original_type, $index);
|
||
|
||
// Don't add NULL values to the return array. Also, adding an empty
|
||
// array is, of course, a waste of time.
|
||
if (isset($v) && $v !== array()) {
|
||
$ret = array_merge($ret, is_array($v) ? $v : array($v));
|
||
}
|
||
}
|
||
}
|
||
return $ret;
|
||
}
|
||
if (!isset($value)) {
|
||
// For text fields, we have to return an array even if the value is NULL.
|
||
return search_api_is_text_type($type, array('text', 'tokens')) ? array() : NULL;
|
||
}
|
||
switch ($type) {
|
||
case 'text':
|
||
$ret = array();
|
||
foreach (preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) as $v) {
|
||
if ($v) {
|
||
$ret[] = array(
|
||
'value' => $v,
|
||
'score' => 1.0,
|
||
);
|
||
}
|
||
}
|
||
$value = $ret;
|
||
// FALL-THROUGH!
|
||
case 'tokens':
|
||
while (TRUE) {
|
||
foreach ($value as $i => $v) {
|
||
// Check for over-long tokens.
|
||
$score = $v['score'];
|
||
$v = $v['value'];
|
||
if (drupal_strlen($v) > 50) {
|
||
$words = preg_split('/[^\p{L}\p{N}]+/u', $v, -1, PREG_SPLIT_NO_EMPTY);
|
||
if (count($words) > 1 && max(array_map('drupal_strlen', $words)) <= 50) {
|
||
// Overlong token is due to bad tokenizing.
|
||
// Check for "Tokenizer" preprocessor on index.
|
||
if (empty($index->options['processors']['search_api_tokenizer']['status'])) {
|
||
watchdog('search_api_db', 'An overlong word (more than 50 characters) was encountered while indexing, due to bad tokenizing. ' .
|
||
'It is recommended to enable the "Tokenizer" preprocessor for indexes using database servers. ' .
|
||
'Otherwise, the service class has to use its own, fixed tokenizing.', array(), WATCHDOG_WARNING);
|
||
}
|
||
else {
|
||
watchdog('search_api_db', 'An overlong word (more than 50 characters) was encountered while indexing, due to bad tokenizing. ' .
|
||
'Please check your settings for the "Tokenizer" preprocessor to ensure that data is tokenized correctly.',
|
||
array(), WATCHDOG_WARNING);
|
||
}
|
||
}
|
||
|
||
$tokens = array();
|
||
foreach ($words as $word) {
|
||
if (drupal_strlen($word) > 50) {
|
||
watchdog('search_api_db', 'An overlong word (more than 50 characters) was encountered while indexing: %word.<br />' .
|
||
'Database search servers currently cannot index such words correctly – the word was therefore trimmed to the allowed length.',
|
||
array('%word' => $word), WATCHDOG_WARNING);
|
||
$word = drupal_substr($word, 0, 50);
|
||
}
|
||
$tokens[] = array(
|
||
'value' => $word,
|
||
'score' => $score,
|
||
);
|
||
}
|
||
array_splice($value, $i, 1, $tokens);
|
||
continue 2;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
return $value;
|
||
|
||
case 'string':
|
||
case 'uri':
|
||
// For non-dates, PHP can handle this well enough
|
||
if ($original_type == 'date') {
|
||
return date('%c', $value);
|
||
}
|
||
if (drupal_strlen($value) > 255) {
|
||
throw new SearchApiException(t('A string value longer than 255 characters was encountered. ' .
|
||
"Such values currently aren't supported by the database backend."));
|
||
}
|
||
return $value;
|
||
|
||
case 'integer':
|
||
case 'duration':
|
||
case 'decimal':
|
||
return 0 + $value;
|
||
|
||
case 'boolean':
|
||
return $value ? 1 : 0;
|
||
|
||
case 'date':
|
||
if (is_numeric($value) || !$value) {
|
||
return 0 + $value;
|
||
}
|
||
return strtotime($value);
|
||
|
||
default:
|
||
throw new SearchApiException(t('Unknown field type @type. Database search module might be out of sync with Search API.', array('@type' => $type)));
|
||
}
|
||
}
|
||
|
||
public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {
|
||
if (!$index) {
|
||
if (empty($this->options['indexes'])) {
|
||
return;
|
||
}
|
||
$set = $this->setDb();
|
||
foreach ($this->options['indexes'] as $index) {
|
||
foreach ($index as $fields) {
|
||
foreach ($fields as $field) {
|
||
db_truncate($field['table'], $this->query_options)->execute();
|
||
}
|
||
}
|
||
}
|
||
if ($set) {
|
||
$this->resetDb();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (empty($this->options['indexes'][$index->machine_name])) {
|
||
return;
|
||
}
|
||
$set = $this->setDb();
|
||
foreach ($this->options['indexes'][$index->machine_name] as $field) {
|
||
if (is_array($ids)) {
|
||
db_delete($field['table'], $this->query_options)
|
||
->condition('item_id', $ids, 'IN')
|
||
->execute();
|
||
}
|
||
else {
|
||
db_truncate($field['table'], $this->query_options)->execute();
|
||
}
|
||
}
|
||
if ($set) {
|
||
$this->resetDb();
|
||
}
|
||
}
|
||
|
||
public function search(SearchApiQueryInterface $query) {
|
||
$time_method_called = microtime(TRUE);
|
||
$set = $this->setDb();
|
||
$index = $query->getIndex();
|
||
if (empty($this->options['indexes'][$index->machine_name])) {
|
||
throw new SearchApiException(t('Unknown index @id.', array('@id' => $index->machine_name)));
|
||
}
|
||
$fields = $this->options['indexes'][$index->machine_name];
|
||
|
||
$keys = &$query->getKeys();
|
||
$keys_set = (boolean) $keys;
|
||
$keys = $this->prepareKeys($keys);
|
||
if ($keys && !(is_array($keys) && count($keys) == 1)) {
|
||
$fulltext_fields = $query->getFields();
|
||
if ($fulltext_fields) {
|
||
$_fulltext_fields = $fulltext_fields;
|
||
$fulltext_fields = array();
|
||
foreach ($_fulltext_fields as $name) {
|
||
if (!isset($fields[$name])) {
|
||
throw new SearchApiException(t('Unknown field @field specified as search target.', array('@field' => $name)));
|
||
}
|
||
if (!search_api_is_text_type($fields[$name]['type'])) {
|
||
throw new SearchApiException(t('Cannot perform fulltext search on field @field of type @type.', array('@field' => $name, '@type' => $fields[$name]['type'])));
|
||
}
|
||
$fulltext_fields[$name] = $fields[$name];
|
||
}
|
||
|
||
$db_query = $this->createKeysQuery($keys, $fulltext_fields, $fields);
|
||
if (is_array($keys) && !empty($keys['#negation'])) {
|
||
$db_query->addExpression(':score', 'score', array(':score' => 1));
|
||
}
|
||
}
|
||
else {
|
||
$msg = t('Search keys are given but no fulltext fields are defined.');
|
||
watchdog('search_api_db', $msg, NULL, WATCHDOG_WARNING);
|
||
$this->warnings[$msg] = 1;
|
||
}
|
||
}
|
||
elseif ($keys_set) {
|
||
$msg = t('No valid search keys were present in the query.');
|
||
$this->warnings[$msg] = 1;
|
||
}
|
||
|
||
if (!isset($db_query)) {
|
||
$db_query = db_select($fields['search_api_language']['table'], 't', $this->query_options);
|
||
$db_query->addField('t', 'item_id', 'item_id');
|
||
$db_query->addExpression(':score', 'score', array(':score' => 1));
|
||
}
|
||
|
||
$filter = $query->getFilter();
|
||
if ($filter->getFilters()) {
|
||
$condition = $this->createFilterCondition($filter, $fields, $db_query);
|
||
if ($condition) {
|
||
$db_query->condition($condition);
|
||
}
|
||
}
|
||
|
||
$db_query->addTag('search_api_db_search');
|
||
|
||
$time_processing_done = microtime(TRUE);
|
||
$results = array();
|
||
$count_query = $db_query->countQuery();
|
||
$results['result count'] = $count_query->execute()->fetchField();
|
||
|
||
if ($results['result count']) {
|
||
if ($query->getOption('search_api_facets')) {
|
||
$results['search_api_facets'] = $this->getFacets($query, clone $db_query);
|
||
}
|
||
|
||
$query_options = $query->getOptions();
|
||
if (isset($query_options['offset']) || isset($query_options['limit'])) {
|
||
$offset = isset($query_options['offset']) ? $query_options['offset'] : 0;
|
||
$limit = isset($query_options['limit']) ? $query_options['limit'] : 1000000;
|
||
$db_query->range($offset, $limit);
|
||
}
|
||
|
||
$sort = $query->getSort();
|
||
if ($sort) {
|
||
foreach ($sort as $field_name => $order) {
|
||
if ($order != 'ASC' && $order != 'DESC') {
|
||
$msg = t('Unknown sort order @order. Assuming "ASC".', array('@order' => $order));
|
||
$this->warnings[$msg] = $msg;
|
||
$order = 'ASC';
|
||
}
|
||
if ($field_name == 'search_api_relevance') {
|
||
$db_query->orderBy('score', $order);
|
||
continue;
|
||
}
|
||
if ($field_name == 'search_api_id') {
|
||
$db_query->orderBy('item_id', $order);
|
||
continue;
|
||
}
|
||
if (!isset($fields[$field_name])) {
|
||
throw new SearchApiException(t('Trying to sort on unknown field @field.', array('@field' => $field_name)));
|
||
}
|
||
$field = $fields[$field_name];
|
||
if (search_api_is_list_type($field['type'])) {
|
||
throw new SearchApiException(t('Cannot sort on field @field of a list type.', array('@field' => $field_name)));
|
||
}
|
||
if (search_api_is_text_type($field['type'])) {
|
||
throw new SearchApiException(t('Cannot sort on fulltext field @field.', array('@field' => $field_name)));
|
||
}
|
||
$alias = $this->getTableAlias($field, $db_query);
|
||
$db_query->orderBy($alias . '.value', $order);
|
||
}
|
||
}
|
||
else {
|
||
$db_query->orderBy('score', 'DESC');
|
||
}
|
||
|
||
$result = $db_query->execute();
|
||
$time_queries_done = microtime(TRUE);
|
||
|
||
foreach ($result as $row) {
|
||
$results['results'][$row->item_id] = array(
|
||
'id' => $row->item_id,
|
||
'score' => $row->score,
|
||
);
|
||
}
|
||
}
|
||
else {
|
||
$time_queries_done = microtime(TRUE);
|
||
$results['results'] = array();
|
||
}
|
||
|
||
$results['warnings'] = array_keys($this->warnings);
|
||
$results['ignored'] = array_keys($this->ignored);
|
||
|
||
if ($set) {
|
||
$this->resetDb();
|
||
}
|
||
|
||
$time_end = microtime(TRUE);
|
||
$results['performance'] = array(
|
||
'complete' => $time_end - $time_method_called,
|
||
'preprocessing' => $time_processing_done - $time_method_called,
|
||
'execution' => $time_queries_done - $time_processing_done,
|
||
'postprocessing' => $time_end - $time_queries_done,
|
||
);
|
||
|
||
return $results;
|
||
}
|
||
|
||
/**
|
||
* Helper method for removing unnecessary nested expressions from keys.
|
||
*/
|
||
protected function prepareKeys($keys) {
|
||
if (is_scalar($keys)) {
|
||
$keys = $this->splitKeys($keys);
|
||
return is_array($keys) ? $this->eliminateDuplicates($keys) : $keys;
|
||
}
|
||
elseif (!$keys) {
|
||
return NULL;
|
||
}
|
||
$keys = $this->eliminateDuplicates($this->splitKeys($keys));
|
||
$conj = $keys['#conjunction'];
|
||
$neg = !empty($keys['#negation']);
|
||
foreach ($keys as $i => &$nested) {
|
||
if (is_array($nested)) {
|
||
$nested = $this->prepareKeys($nested);
|
||
if ($neg == !empty($nested['#negation'])) {
|
||
if ($nested['#conjunction'] == $conj) {
|
||
unset($nested['#conjunction'], $nested['#negation']);
|
||
foreach ($nested as $renested) {
|
||
$keys[] = $renested;
|
||
}
|
||
unset($keys[$i]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
$keys = array_filter($keys);
|
||
if (($count = count($keys)) <= 2) {
|
||
if ($count < 2 || isset($keys['#negation'])) {
|
||
$keys = NULL;
|
||
}
|
||
else {
|
||
unset($keys['#conjunction']);
|
||
$keys = array_shift($keys);
|
||
}
|
||
}
|
||
return $keys;
|
||
}
|
||
|
||
/**
|
||
* Helper method for splitting keys.
|
||
*/
|
||
protected function splitKeys($keys) {
|
||
if (is_scalar($keys)) {
|
||
$proc = drupal_strtolower(trim($keys));
|
||
if (is_numeric($proc)) {
|
||
return ltrim($proc, '-0');
|
||
}
|
||
elseif (drupal_strlen($proc) < $this->options['min_chars']) {
|
||
$this->ignored[$keys] = 1;
|
||
return NULL;
|
||
}
|
||
$words = preg_split('/[^\p{L}\p{N}]+/u', $proc, -1, PREG_SPLIT_NO_EMPTY);
|
||
if (count($words) > 1) {
|
||
$proc = $this->splitKeys($words);
|
||
$proc['#conjunction'] = 'AND';
|
||
}
|
||
return $proc;
|
||
}
|
||
foreach ($keys as $i => $key) {
|
||
if (element_child($i)) {
|
||
$keys[$i] = $this->splitKeys($key);
|
||
}
|
||
}
|
||
return array_filter($keys);
|
||
}
|
||
|
||
/**
|
||
* Helper method for eliminating duplicates from the search keys.
|
||
*/
|
||
protected function eliminateDuplicates($keys, &$words = array()) {
|
||
foreach ($keys as $i => $word) {
|
||
if (!element_child($i)) {
|
||
continue;
|
||
}
|
||
if (is_scalar($word)) {
|
||
if (isset($words[$word])) {
|
||
unset($keys[$i]);
|
||
}
|
||
else {
|
||
$words[$word] = TRUE;
|
||
}
|
||
}
|
||
else {
|
||
$keys[$i] = $this->eliminateDuplicates($word, $words);
|
||
}
|
||
}
|
||
return $keys;
|
||
}
|
||
|
||
/**
|
||
* Helper method for creating a SELECT query for given search keys.
|
||
*
|
||
* @return SelectQueryInterface
|
||
* A SELECT query returning item_id and score (or only item_id, if
|
||
* $keys['#negation'] is set).
|
||
*/
|
||
protected function createKeysQuery($keys, array $fields, array $all_fields) {
|
||
if (!is_array($keys)) {
|
||
$keys = array(
|
||
'#conjunction' => 'AND',
|
||
$keys,
|
||
);
|
||
}
|
||
|
||
$or = db_or();
|
||
$neg = !empty($keys['#negation']);
|
||
$conj = $keys['#conjunction'];
|
||
$words = array();
|
||
$nested = array();
|
||
$negated = array();
|
||
$db_query = NULL;
|
||
$mul_words = FALSE;
|
||
$not_nested = FALSE;
|
||
|
||
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;
|
||
}
|
||
}
|
||
$subs = count($words) + count($nested);
|
||
$not_nested = ($subs <= 1 && count($fields) == 1) || ($neg && $conj == 'OR' && !$negated);
|
||
|
||
if ($words) {
|
||
if (count($words) > 1) {
|
||
$mul_words = TRUE;
|
||
foreach ($words as $word) {
|
||
$or->condition('word', $word);
|
||
}
|
||
}
|
||
else {
|
||
$word = array_shift($words);
|
||
}
|
||
foreach ($fields as $name => $field) {
|
||
$table = $field['table'];
|
||
$query = db_select($table, 't', $this->query_options);
|
||
if ($neg) {
|
||
$query->fields('t', array('item_id'));
|
||
}
|
||
elseif ($not_nested) {
|
||
$query->fields('t', array('item_id', 'score'));
|
||
}
|
||
else {
|
||
$query->fields('t');
|
||
}
|
||
if ($mul_words) {
|
||
$query->condition($or);
|
||
}
|
||
else {
|
||
$query->condition('word', $word);
|
||
}
|
||
if (!isset($db_query)) {
|
||
$db_query = $query;
|
||
}
|
||
elseif ($not_nested) {
|
||
$db_query->union($query, 'UNION');
|
||
}
|
||
else {
|
||
$db_query->union($query, 'UNION ALL');
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($nested) {
|
||
$word = '';
|
||
foreach ($nested as $k) {
|
||
$query = $this->createKeysQuery($k, $fields, $all_fields);
|
||
if (!$neg) {
|
||
$word .= ' ';
|
||
$var = ':word' . strlen($word);
|
||
$query->addExpression($var, 'word', array($var => $word));
|
||
}
|
||
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 = db_select($db_query, 't', $this->query_options);
|
||
$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 ($mul_words) {
|
||
$db_query->having('COUNT(DISTINCT t.word) >= ' . $var, array($var => $subs));
|
||
}
|
||
else {
|
||
$db_query->having('COUNT(DISTINCT t.word) >= ' . $var, array($var => $subs));
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($negated) {
|
||
if (!isset($db_query) || $conj == 'OR') {
|
||
if (isset($all_fields['search_api_language'])) {
|
||
// We use this table because all items should be contained exactly once.
|
||
$table = $all_fields['search_api_language']['table'];
|
||
}
|
||
else {
|
||
$distinct = TRUE;
|
||
foreach ($all_fields as $field) {
|
||
$table = $field['table'];
|
||
if (!search_api_is_list_type($field['type']) && !search_api_is_text_type($field['type'])) {
|
||
unset($distinct);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
$db_query = db_select($table, 't', $this->query_options);
|
||
$db_query->addField('t', 'item_id', 'item_id');
|
||
if (!$neg) {
|
||
$db_query->addExpression(':score', 'score', array(':score' => 1));
|
||
}
|
||
if (isset($distinct)) {
|
||
$db_query->distinct();
|
||
}
|
||
}
|
||
|
||
if ($conj == 'AND') {
|
||
foreach ($negated as $k) {
|
||
$db_query->condition('t.item_id', $this->createKeysQuery($k, $fields, $all_fields), 'NOT IN');
|
||
}
|
||
}
|
||
else {
|
||
$or = db_or();
|
||
foreach ($negated as $k) {
|
||
$or->condition('t.item_id', $this->createKeysQuery($k, $fields, $all_fields), 'NOT IN');
|
||
}
|
||
if (isset($old_query)) {
|
||
$or->condition('t.item_id', $old_query, 'NOT IN');
|
||
}
|
||
$db_query->condition($or);
|
||
}
|
||
}
|
||
|
||
return $db_query;
|
||
}
|
||
|
||
/**
|
||
* Helper method for finding any needed table for a filter query.
|
||
*/
|
||
protected function findTable(array $filters, array $fields) {
|
||
foreach ($filters as $filter) {
|
||
if (is_array($filter)) {
|
||
return $fields[$filter[0]]['table'];
|
||
}
|
||
}
|
||
foreach ($filters as $filter) {
|
||
if (is_object($filter)) {
|
||
$ret = $this->findTable($filter->getFilters(), $fields);
|
||
if ($ret) {
|
||
return $ret;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Helper method for creating a condition for filtering search results.
|
||
*
|
||
* @return QueryConditionInterface
|
||
*/
|
||
protected function createFilterCondition(SearchApiQueryFilterInterface $filter, array $fields, SelectQueryInterface $db_query) {
|
||
$cond = db_condition($filter->getConjunction());
|
||
$empty = TRUE;
|
||
foreach ($filter->getFilters() as $f) {
|
||
if (is_object($f)) {
|
||
$c = $this->createFilterCondition($f, $fields, $db_query);
|
||
if ($c) {
|
||
$empty = FALSE;
|
||
$cond->condition($c);
|
||
}
|
||
}
|
||
else {
|
||
$empty = FALSE;
|
||
if (!isset($fields[$f[0]])) {
|
||
throw new SearchApiException(t('Unknown field in filter clause: @field.', array('@field' => $f[0])));
|
||
}
|
||
if ($f[1] === NULL) {
|
||
$query = db_select($fields[$f[0]]['table'], 't')
|
||
->fields('t', array('item_id'));
|
||
$cond->condition('t.item_id', $query, $f[2] == '<>' || $f[2] == '!=' ? 'IN' : 'NOT IN');
|
||
continue;
|
||
}
|
||
if (search_api_is_text_type($fields[$f[0]]['type'])) {
|
||
// #negation is set here purely because we don't want any score.
|
||
$keys = array('#conjunction' => 'AND', '#negation' => TRUE, $f[1]);
|
||
$keys = $this->prepareKeys($keys);
|
||
$query = $this->createKeysQuery($keys, array($fields[$f[0]]), $fields);
|
||
$cond->condition('t.item_id', $query, $f[2] == '<>' || $f[2] == '!=' ? 'NOT IN' : 'IN');
|
||
}
|
||
else {
|
||
$alias = $this->getTableAlias($fields[$f[0]], $db_query, search_api_is_list_type($fields[$f[0]]['type']));
|
||
$cond->condition($alias . '.value', $f[1], $f[2]);
|
||
}
|
||
}
|
||
}
|
||
return $empty ? NULL : $cond;
|
||
}
|
||
|
||
/**
|
||
* Helper method for adding a field's table to a database query.
|
||
*
|
||
* @param array $field
|
||
* The field information array. The "table" key should contain the table
|
||
* name to which a join should be made.
|
||
* @param SelectQueryInterface $db_query
|
||
* The database query used.
|
||
* @param $newjoin
|
||
* If TRUE, a join is done even if the table was already joined to in the
|
||
* query.
|
||
*/
|
||
protected function getTableAlias(array $field, SelectQueryInterface $db_query, $newjoin = FALSE) {
|
||
if(!$newjoin) {
|
||
foreach ($db_query->getTables() as $alias => $info) {
|
||
$table = $info['table'];
|
||
if (is_scalar($table) && $table == $field['table']) {
|
||
return $alias;
|
||
}
|
||
}
|
||
}
|
||
return $db_query->join($field['table'], 't', 't.item_id = %alias.item_id');
|
||
}
|
||
|
||
/**
|
||
* Helper method for getting the facet values for a query.
|
||
*/
|
||
protected function getFacets(SearchApiQueryInterface $query, SelectQueryInterface $db_query) {
|
||
// We only need the id field, not the score.
|
||
$fields = &$db_query->getFields();
|
||
unset($fields['score']);
|
||
if (count($fields) != 1 || !isset($fields['item_id'])) {
|
||
$this->warnings[] = t('Error while adding facets: only "item_id" field should be used, used are: @fields.',
|
||
array('@fields' => implode(', ', array_keys($fields))));
|
||
return array();
|
||
}
|
||
$expressions = &$db_query->getExpressions();
|
||
$expressions = array();
|
||
$group_by = &$db_query->getGroupBy();
|
||
$group_by = array();
|
||
$db_query->distinct();
|
||
if (!$db_query->preExecute()) {
|
||
return array();
|
||
}
|
||
$args = $db_query->getArguments();
|
||
$table = db_query_temporary((string) $db_query, $args, $this->query_options);
|
||
|
||
$fields = $this->options['indexes'][$query->getIndex()->machine_name];
|
||
$ret = array();
|
||
foreach ($query->getOption('search_api_facets') as $key => $facet) {
|
||
if (empty($fields[$facet['field']])) {
|
||
$this->warnings[] = t('Unknown facet field @field.', array('@field' => $facet['field']));
|
||
continue;
|
||
}
|
||
$field = $fields[$facet['field']];
|
||
$missing_count = 0;
|
||
|
||
$select = db_select($table, 't');
|
||
$alias = $this->getTableAlias($field, $select, TRUE);
|
||
$select->addField($alias, search_api_is_text_type($field['type']) ? 'word' : 'value', 'value');
|
||
$select->addExpression('COUNT(DISTINCT t.item_id)', 'num');
|
||
$select->groupBy('value');
|
||
$select->orderBy('num', 'DESC');
|
||
|
||
$limit = $facet['limit'];
|
||
if ((int) $limit > 0) {
|
||
$select->range(0, $limit);
|
||
}
|
||
if ($facet['min_count'] > 1) {
|
||
$select->having('num >= :count', array(':count' => $facet['min_count']));
|
||
}
|
||
if (((bool) $facet['missing']) === search_api_is_list_type($field['type'])) {
|
||
// For multi-valued fields, the "missing" facet is defined by the table
|
||
// not having any entries for those items. We therefore need to execute
|
||
// an additional query, counting the items which do not have any entries
|
||
// in the field table.
|
||
if ($facet['missing']) {
|
||
$inner_query = db_select($field['table'], 't1')
|
||
->fields('t1', array('item_id'));
|
||
$missing_count = db_select($table, 't');
|
||
$missing_count->addExpression('COUNT(item_id)');
|
||
$missing_count->condition('item_id', $inner_query, 'NOT IN');
|
||
$missing_count = $missing_count->execute()->fetchField();
|
||
$missing_count = $missing_count >= $facet['min_count'] ? $missing_count : 0;
|
||
}
|
||
// For single-valued fields, there are explicit NULL values in the
|
||
// table for "missing" entries. Therefore, we have to exclude NULL
|
||
// values if we do not want a "missing" facet.
|
||
else {
|
||
$select->isNotNull('value');
|
||
}
|
||
}
|
||
|
||
$terms = array();
|
||
foreach ($select->execute() as $row) {
|
||
if ($missing_count && $missing_count > $row->num) {
|
||
$terms[] = array(
|
||
'count' => $missing_count,
|
||
'filter' => '!',
|
||
);
|
||
$missing_count = 0;
|
||
if ($limit && count($terms) == $limit) {
|
||
break;
|
||
}
|
||
}
|
||
$terms[] = array(
|
||
'count' => $row->num,
|
||
'filter' => isset($row->value) ? '"' . $row->value . '"' : '!',
|
||
);
|
||
}
|
||
if ($missing_count && (!$limit || count($terms) < $limit)) {
|
||
$terms[] = array(
|
||
'count' => $missing_count,
|
||
'filter' => '!',
|
||
);
|
||
}
|
||
$ret[$key] = $terms;
|
||
}
|
||
return $ret;
|
||
}
|
||
|
||
/**
|
||
* Helper method for setting the database to the one selected by the user.
|
||
*/
|
||
protected function setDb() {
|
||
if (!isset($this->previous_db)) {
|
||
list($key, $target) = explode(':', $this->options['database'], 2);
|
||
$this->previous_db = db_set_active($key);
|
||
if (!isset($this->query_options)) {
|
||
$this->query_options = array('target' => $target);
|
||
}
|
||
return TRUE;
|
||
}
|
||
return FALSE;
|
||
}
|
||
|
||
/**
|
||
* Helper method for resetting the original database.
|
||
*/
|
||
protected function resetDb() {
|
||
if (isset($this->previous_db)) {
|
||
db_set_active($this->previous_db);
|
||
$this->previous_db = NULL;
|
||
return TRUE;
|
||
}
|
||
return FALSE;
|
||
}
|
||
|
||
}
|