service.inc 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197
  1. <?php
  2. /**
  3. * Search service class using the database for storing index information.
  4. */
  5. class SearchApiDbService extends SearchApiAbstractService {
  6. protected $previous_db;
  7. protected $query_options = array();
  8. protected $ignored = array();
  9. protected $warnings = array();
  10. public function configurationForm(array $form, array &$form_state) {
  11. if (empty($this->options)) {
  12. global $databases;
  13. foreach ($databases as $key => $targets) {
  14. foreach ($targets as $target => $info) {
  15. $options[$key]["$key:$target"] = "$key > $target";
  16. }
  17. }
  18. if (count($options) > 1 || count(reset($options)) > 1) {
  19. $form['database'] = array(
  20. '#type' => 'select',
  21. '#title' => t('Database'),
  22. '#description' => t('Select the database key and target to use for storing indexing information in. ' .
  23. 'Cannot be changed after creation.'),
  24. '#options' => $options,
  25. '#default_value' => 'default:default',
  26. '#required' => TRUE,
  27. );
  28. }
  29. else {
  30. $form['database'] = array(
  31. '#type' => 'value',
  32. '#value' => "$key:$target",
  33. );
  34. }
  35. $form['min_chars'] = array(
  36. '#type' => 'select',
  37. '#title' => t('Minimum word length'),
  38. '#description' => t('The minimum number of characters a word must consist of to be indexed.'),
  39. '#options' => drupal_map_assoc(array(1, 2, 3, 4, 5, 6)),
  40. '#default_value' => 1,
  41. );
  42. }
  43. else {
  44. $form = array(
  45. 'database' => array(
  46. '#type' => 'value',
  47. '#title' => t('Database'), // Slight hack for the "View server" page.
  48. '#value' => $this->options['database'],
  49. ),
  50. 'database_text' => array(
  51. '#type' => 'item',
  52. '#title' => t('Database'),
  53. '#markup' => check_plain(str_replace(':', ' > ', $this->options['database'])),
  54. ),
  55. 'min_chars' => array(
  56. '#type' => 'select',
  57. '#title' => t('Minimum word length'),
  58. '#description' => t('The minimum number of characters a word must consist of to be indexed.'),
  59. '#options' => drupal_map_assoc(array(1, 2, 3, 4, 5, 6)),
  60. '#default_value' => $this->options['min_chars'],
  61. ),
  62. );
  63. }
  64. return $form;
  65. }
  66. public function supportsFeature($feature) {
  67. return $feature == 'search_api_facets';
  68. }
  69. public function postUpdate() {
  70. return $this->server->options != $this->server->original->options;
  71. }
  72. public function preDelete() {
  73. if (empty($this->options['indexes'])) {
  74. return;
  75. }
  76. foreach ($this->options['indexes'] as $index) {
  77. foreach ($index as $field) {
  78. db_drop_table($field['table']);
  79. }
  80. }
  81. }
  82. public function addIndex(SearchApiIndex $index) {
  83. $this->options += array('indexes' => array());
  84. $indexes = &$this->options['indexes'];
  85. if (isset($indexes[$index->machine_name])) {
  86. // Easiest and safest method to ensure all of the index' data is properly re-added.
  87. $this->removeIndex($index);
  88. }
  89. if (empty($index->options['fields']) || !is_array($index->options['fields'])) {
  90. // No fields, no work.
  91. $indexes[$index->machine_name] = array();
  92. $this->server->save();
  93. return $this;
  94. }
  95. $prefix = 'search_api_db_' . $index->machine_name. '_';
  96. $indexes[$index->machine_name] = array();
  97. foreach ($index->getFields() as $name => $field) {
  98. $table = $this->findFreeTable($prefix, $name);
  99. $this->createFieldTable($index, $field, $table);
  100. $indexes[$index->machine_name][$name]['table'] = $table;
  101. $indexes[$index->machine_name][$name]['type'] = $field['type'];
  102. $indexes[$index->machine_name][$name]['boost'] = $field['boost'];
  103. }
  104. $this->server->save();
  105. }
  106. /**
  107. * Helper method for finding free table names for fields.
  108. *
  109. * MySQL 5.0 imposes a 64 characters length limit for table names, PostgreSQL
  110. * 8.3 only allows 63 characters. Therefore, always return a name at most 63
  111. * characters long.
  112. */
  113. protected function findFreeTable($prefix, $name) {
  114. // A DB prefix might further reduce the maximum length of the table name.
  115. $maxlen = 63;
  116. list($key, $target) = explode(':', $this->options['database'], 2);
  117. if ($db_prefix = Database::getConnection($target, $key)->tablePrefix()) {
  118. $maxlen -= drupal_strlen($db_prefix);
  119. }
  120. $base = $table = drupal_substr($prefix . drupal_strtolower(preg_replace('/[^a-z0-9]/i', '_', $name)), 0, $maxlen);
  121. $i = 0;
  122. while (db_table_exists($table)) {
  123. $suffix = '_' . ++$i;
  124. $table = drupal_substr($base, 0, $maxlen - drupal_strlen($suffix)) . $suffix;
  125. }
  126. return $table;
  127. }
  128. /**
  129. * Helper method for creating the table for a field.
  130. */
  131. protected function createFieldTable(SearchApiIndex $index, $field, $name) {
  132. $table = array(
  133. 'name' => $name,
  134. 'module' => 'search_api_db',
  135. 'fields' => array(
  136. 'item_id' => array(
  137. 'description' => 'The primary identifier of the item.',
  138. 'not null' => TRUE,
  139. ),
  140. ),
  141. );
  142. // The type of the item_id field depends on the ID field's type.
  143. $id_field = $index->datasource()->getIdFieldInfo();
  144. $table['fields']['item_id'] += $this->sqlType($id_field['type'] == 'text' ? 'string' : $id_field['type']);
  145. if (isset($table['fields']['item_id']['length'])) {
  146. // A length of 255 is overkill for IDs. 50 should be more than enough.
  147. $table['fields']['item_id']['length'] = 50;
  148. }
  149. $type = search_api_extract_inner_type($field['type']);
  150. if ($type == 'text') {
  151. $table['fields']['word'] = array(
  152. 'description' => 'The text of the indexed token.',
  153. 'type' => 'varchar',
  154. 'length' => 50,
  155. 'not null' => TRUE,
  156. );
  157. $table['fields']['score'] = array(
  158. 'description' => 'The score associated with this token.',
  159. 'type' => 'float',
  160. 'not null' => TRUE,
  161. );
  162. $table['primary key'] = array('item_id', 'word');
  163. $table['indexes']['word'] = array(array('word', 10));
  164. }
  165. else {
  166. $table['fields']['value'] = $this->sqlType($type);
  167. $table['fields']['value'] += array('description' => "The field's value for this item.");
  168. if ($type != $field['type']) {
  169. // This is a list type.
  170. $table['fields']['value']['not null'] = TRUE;
  171. $table['primary key'] = array('item_id', 'value');
  172. }
  173. else {
  174. $table['primary key'] = array('item_id');
  175. }
  176. $table['indexes']['value'] = $table['fields']['value'] == 'varchar' ? array(array('value', 10)) : array('value');
  177. }
  178. $set = $this->setDb();
  179. db_create_table($name, $table);
  180. if ($set) {
  181. $this->resetDb();
  182. }
  183. }
  184. protected function sqlType($type) {
  185. $type = search_api_extract_inner_type($type);
  186. switch ($type) {
  187. case 'string':
  188. case 'uri':
  189. return array('type' => 'varchar', 'length' => 255);
  190. case 'integer':
  191. case 'duration':
  192. case 'date': // 'datetime' sucks. This way, we just convert every input into a timestamp.
  193. return array('type' => 'int');
  194. case 'decimal':
  195. return array('type' => 'float');
  196. case 'boolean':
  197. return array('type' => 'int', 'size' => 'tiny');
  198. default:
  199. throw new SearchApiException(t('Unknown field type @type. Database search module might be out of sync with Search API.', array('@type' => $type)));
  200. }
  201. }
  202. public function fieldsUpdated(SearchApiIndex $index) {
  203. $fields = &$this->options['indexes'][$index->machine_name];
  204. $new_fields = $index->getFields();
  205. $reindex = FALSE;
  206. $cleared = FALSE;
  207. $set = $this->setDb();
  208. foreach ($fields as $name => $field) {
  209. if (!isset($new_fields[$name])) {
  210. db_drop_table($field['table']);
  211. unset($fields[$name]);
  212. continue;
  213. }
  214. $old_type = $field['type'];
  215. $new_type = $new_fields[$name]['type'];
  216. $fields[$name]['type'] = $new_type;
  217. $fields[$name]['boost'] = $new_fields[$name]['boost'];
  218. $old_inner_type = search_api_extract_inner_type($old_type);
  219. $new_inner_type = search_api_extract_inner_type($new_type);
  220. if ($old_type != $new_type) {
  221. if ($old_inner_type == 'text' || $new_inner_type == 'text'
  222. || search_api_list_nesting_level($old_type) != search_api_list_nesting_level($new_type)) {
  223. // A change in fulltext or list status necessitates completely
  224. // clearing the index.
  225. $reindex = TRUE;
  226. if (!$cleared) {
  227. $cleared = TRUE;
  228. $this->deleteItems('all', $index);
  229. }
  230. db_drop_table($field['table']);
  231. $this->createFieldTable($index, $new_fields[$name], $field['table']);
  232. }
  233. elseif ($this->sqlType($old_inner_type) != $this->sqlType($new_inner_type)) {
  234. // There is a change in SQL type. We don't have to clear the index, since types can be converted.
  235. db_change_field($field['table'], 'value', 'value', $this->sqlType($new_type) + array('description' => "The field's value for this item."));
  236. $reindex = TRUE;
  237. }
  238. elseif ($old_inner_type == 'date' || $new_inner_type == 'date') {
  239. // Even though the SQL type stays the same, we have to reindex since conversion rules change.
  240. $reindex = TRUE;
  241. }
  242. }
  243. elseif (!$reindex && $new_inner_type == 'text' && $field['boost'] != $new_fields[$name]['boost']) {
  244. $multiplier = $new_fields[$name]['boost'] / $field['boost'];
  245. db_update($field['table'], $this->query_options)
  246. ->expression('score', 'score * :mult', array(':mult' => $multiplier))
  247. ->execute();
  248. }
  249. unset($new_fields[$name]);
  250. }
  251. $prefix = 'search_api_db_' . $index->machine_name. '_';
  252. // These are new fields that were previously not indexed.
  253. foreach ($new_fields as $name => $field) {
  254. $reindex = TRUE;
  255. $table = $this->findFreeTable($prefix, $name);
  256. $this->createFieldTable($index, $field, $table);
  257. $fields[$name]['table'] = $table;
  258. $fields[$name]['type'] = $field['type'];
  259. $fields[$name]['boost'] = $field['boost'];
  260. }
  261. if ($set) {
  262. $this->resetDb();
  263. }
  264. $this->server->save();
  265. return $reindex;
  266. }
  267. public function removeIndex($index) {
  268. $id = is_object($index) ? $index->machine_name : $index;
  269. if (!isset($this->options['indexes'][$id])) {
  270. return;
  271. }
  272. $set = $this->setDb();
  273. foreach ($this->options['indexes'][$id] as $field) {
  274. db_drop_table($field['table']);
  275. }
  276. if ($set) {
  277. $this->resetDb();
  278. }
  279. unset($this->options['indexes'][$id]);
  280. $this->server->save();
  281. }
  282. public function indexItems(SearchApiIndex $index, array $items) {
  283. if (empty($this->options['indexes'][$index->machine_name])) {
  284. throw new SearchApiException(t('No field settings for index with id @id.', array('@id' => $index->machine_name)));
  285. }
  286. $indexed = array();
  287. $set = $this->setDb();
  288. foreach ($items as $id => $item) {
  289. try {
  290. if ($this->indexItem($index, $id, $item)) {
  291. $indexed[] = $id;
  292. }
  293. }
  294. catch (Exception $e) {
  295. // We just log the error, hoping we can index the other items.
  296. watchdog('search_api_db', check_plain($e->getMessage()), NULL, WATCHDOG_WARNING);
  297. }
  298. }
  299. if ($set) {
  300. $this->resetDb();
  301. }
  302. return $indexed;
  303. }
  304. protected function indexItem(SearchApiIndex $index, $id, array $item) {
  305. $fields = $this->options['indexes'][$index->machine_name];
  306. $txn = db_transaction('search_api_indexing', $this->query_options);
  307. try {
  308. foreach ($item as $name => $field) {
  309. $table = $fields[$name]['table'];
  310. $boost = $fields[$name]['boost'];
  311. db_delete($table, $this->query_options)
  312. ->condition('item_id', $id)
  313. ->execute();
  314. // Don't index null values
  315. if($field['value'] === NULL) {
  316. continue;
  317. }
  318. $type = $field['type'];
  319. $value = $this->convert($field['value'], $type, $field['original_type'], $index);
  320. if (search_api_is_text_type($type, array('text', 'tokens'))) {
  321. $words = array();
  322. foreach ($value as $token) {
  323. // Taken from core search to reflect less importance of words later
  324. // in the text.
  325. // Focus is a decaying value in terms of the amount of unique words
  326. // up to this point. From 100 words and more, it decays, to e.g. 0.5
  327. // at 500 words and 0.3 at 1000 words.
  328. $focus = min(1, .01 + 3.5 / (2 + count($words) * .015));
  329. $value = &$token['value'];
  330. if (is_numeric($value)) {
  331. $value = ltrim($value, '-0');
  332. }
  333. elseif (drupal_strlen($value) < $this->options['min_chars']) {
  334. continue;
  335. }
  336. $value = drupal_strtolower($value);
  337. $token['score'] *= $focus;
  338. if (!isset($words[$value])) {
  339. $words[$value] = $token;
  340. }
  341. else {
  342. $words[$value]['score'] += $token['score'];
  343. }
  344. }
  345. if ($words) {
  346. $query = db_insert($table, $this->query_options)
  347. ->fields(array('item_id', 'word', 'score'));
  348. foreach ($words as $word) {
  349. $query->values(array(
  350. 'item_id' => $id,
  351. 'word' => $word['value'],
  352. 'score' => $word['score'] * $boost,
  353. ));
  354. }
  355. $query->execute();
  356. }
  357. }
  358. elseif (search_api_is_list_type($type)) {
  359. $values = array();
  360. if (is_array($value)) {
  361. foreach ($value as $v) {
  362. if ($v !== NULL) {
  363. $values[$v] = TRUE;
  364. }
  365. }
  366. $values = array_keys($values);
  367. }
  368. else {
  369. $values[] = $value;
  370. }
  371. if ($values) {
  372. $insert = db_insert($table, $this->query_options)
  373. ->fields(array('item_id', 'value'));
  374. foreach ($values as $v) {
  375. $insert->values(array(
  376. 'item_id' => $id,
  377. 'value' => $v,
  378. ));
  379. }
  380. $insert->execute();
  381. }
  382. }
  383. elseif (isset($value)) {
  384. db_insert($table, $this->query_options)
  385. ->fields(array(
  386. 'item_id' => $id,
  387. 'value' => $value,
  388. ))
  389. ->execute();
  390. }
  391. }
  392. }
  393. catch (Exception $e) {
  394. $txn->rollback();
  395. throw $e;
  396. }
  397. return TRUE;
  398. }
  399. protected function convert($value, $type, $original_type, SearchApiIndex $index) {
  400. if (search_api_is_list_type($type)) {
  401. $type = substr($type, 5, -1);
  402. $original_type = search_api_extract_inner_type($original_type);
  403. $ret = array();
  404. if (is_array($value)) {
  405. foreach ($value as $v) {
  406. $v = $this->convert($v, $type, $original_type, $index);
  407. // Don't add NULL values to the return array. Also, adding an empty
  408. // array is, of course, a waste of time.
  409. if (isset($v) && $v !== array()) {
  410. $ret = array_merge($ret, is_array($v) ? $v : array($v));
  411. }
  412. }
  413. }
  414. return $ret;
  415. }
  416. if (!isset($value)) {
  417. // For text fields, we have to return an array even if the value is NULL.
  418. return search_api_is_text_type($type, array('text', 'tokens')) ? array() : NULL;
  419. }
  420. switch ($type) {
  421. case 'text':
  422. $ret = array();
  423. foreach (preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) as $v) {
  424. if ($v) {
  425. $ret[] = array(
  426. 'value' => $v,
  427. 'score' => 1.0,
  428. );
  429. }
  430. }
  431. $value = $ret;
  432. // FALL-THROUGH!
  433. case 'tokens':
  434. while (TRUE) {
  435. foreach ($value as $i => $v) {
  436. // Check for over-long tokens.
  437. $score = $v['score'];
  438. $v = $v['value'];
  439. if (drupal_strlen($v) > 50) {
  440. $words = preg_split('/[^\p{L}\p{N}]+/u', $v, -1, PREG_SPLIT_NO_EMPTY);
  441. if (count($words) > 1 && max(array_map('drupal_strlen', $words)) <= 50) {
  442. // Overlong token is due to bad tokenizing.
  443. // Check for "Tokenizer" preprocessor on index.
  444. if (empty($index->options['processors']['search_api_tokenizer']['status'])) {
  445. watchdog('search_api_db', 'An overlong word (more than 50 characters) was encountered while indexing, due to bad tokenizing. ' .
  446. 'It is recommended to enable the "Tokenizer" preprocessor for indexes using database servers. ' .
  447. 'Otherwise, the service class has to use its own, fixed tokenizing.', array(), WATCHDOG_WARNING);
  448. }
  449. else {
  450. watchdog('search_api_db', 'An overlong word (more than 50 characters) was encountered while indexing, due to bad tokenizing. ' .
  451. 'Please check your settings for the "Tokenizer" preprocessor to ensure that data is tokenized correctly.',
  452. array(), WATCHDOG_WARNING);
  453. }
  454. }
  455. $tokens = array();
  456. foreach ($words as $word) {
  457. if (drupal_strlen($word) > 50) {
  458. watchdog('search_api_db', 'An overlong word (more than 50 characters) was encountered while indexing: %word.<br />' .
  459. 'Database search servers currently cannot index such words correctly – the word was therefore trimmed to the allowed length.',
  460. array('%word' => $word), WATCHDOG_WARNING);
  461. $word = drupal_substr($word, 0, 50);
  462. }
  463. $tokens[] = array(
  464. 'value' => $word,
  465. 'score' => $score,
  466. );
  467. }
  468. array_splice($value, $i, 1, $tokens);
  469. continue 2;
  470. }
  471. }
  472. break;
  473. }
  474. return $value;
  475. case 'string':
  476. case 'uri':
  477. // For non-dates, PHP can handle this well enough
  478. if ($original_type == 'date') {
  479. return date('%c', $value);
  480. }
  481. if (drupal_strlen($value) > 255) {
  482. throw new SearchApiException(t('A string value longer than 255 characters was encountered. ' .
  483. "Such values currently aren't supported by the database backend."));
  484. }
  485. return $value;
  486. case 'integer':
  487. case 'duration':
  488. case 'decimal':
  489. return 0 + $value;
  490. case 'boolean':
  491. return $value ? 1 : 0;
  492. case 'date':
  493. if (is_numeric($value) || !$value) {
  494. return 0 + $value;
  495. }
  496. return strtotime($value);
  497. default:
  498. throw new SearchApiException(t('Unknown field type @type. Database search module might be out of sync with Search API.', array('@type' => $type)));
  499. }
  500. }
  501. public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {
  502. if (!$index) {
  503. if (empty($this->options['indexes'])) {
  504. return;
  505. }
  506. $set = $this->setDb();
  507. foreach ($this->options['indexes'] as $index) {
  508. foreach ($index as $fields) {
  509. foreach ($fields as $field) {
  510. db_truncate($field['table'], $this->query_options)->execute();
  511. }
  512. }
  513. }
  514. if ($set) {
  515. $this->resetDb();
  516. }
  517. return;
  518. }
  519. if (empty($this->options['indexes'][$index->machine_name])) {
  520. return;
  521. }
  522. $set = $this->setDb();
  523. foreach ($this->options['indexes'][$index->machine_name] as $field) {
  524. if (is_array($ids)) {
  525. db_delete($field['table'], $this->query_options)
  526. ->condition('item_id', $ids, 'IN')
  527. ->execute();
  528. }
  529. else {
  530. db_truncate($field['table'], $this->query_options)->execute();
  531. }
  532. }
  533. if ($set) {
  534. $this->resetDb();
  535. }
  536. }
  537. public function search(SearchApiQueryInterface $query) {
  538. $time_method_called = microtime(TRUE);
  539. $set = $this->setDb();
  540. $index = $query->getIndex();
  541. if (empty($this->options['indexes'][$index->machine_name])) {
  542. throw new SearchApiException(t('Unknown index @id.', array('@id' => $index->machine_name)));
  543. }
  544. $fields = $this->options['indexes'][$index->machine_name];
  545. $keys = &$query->getKeys();
  546. $keys_set = (boolean) $keys;
  547. $keys = $this->prepareKeys($keys);
  548. if ($keys && !(is_array($keys) && count($keys) == 1)) {
  549. $fulltext_fields = $query->getFields();
  550. if ($fulltext_fields) {
  551. $_fulltext_fields = $fulltext_fields;
  552. $fulltext_fields = array();
  553. foreach ($_fulltext_fields as $name) {
  554. if (!isset($fields[$name])) {
  555. throw new SearchApiException(t('Unknown field @field specified as search target.', array('@field' => $name)));
  556. }
  557. if (!search_api_is_text_type($fields[$name]['type'])) {
  558. throw new SearchApiException(t('Cannot perform fulltext search on field @field of type @type.', array('@field' => $name, '@type' => $fields[$name]['type'])));
  559. }
  560. $fulltext_fields[$name] = $fields[$name];
  561. }
  562. $db_query = $this->createKeysQuery($keys, $fulltext_fields, $fields);
  563. if (is_array($keys) && !empty($keys['#negation'])) {
  564. $db_query->addExpression(':score', 'score', array(':score' => 1));
  565. }
  566. }
  567. else {
  568. $msg = t('Search keys are given but no fulltext fields are defined.');
  569. watchdog('search_api_db', $msg, NULL, WATCHDOG_WARNING);
  570. $this->warnings[$msg] = 1;
  571. }
  572. }
  573. elseif ($keys_set) {
  574. $msg = t('No valid search keys were present in the query.');
  575. $this->warnings[$msg] = 1;
  576. }
  577. if (!isset($db_query)) {
  578. $db_query = db_select($fields['search_api_language']['table'], 't', $this->query_options);
  579. $db_query->addField('t', 'item_id', 'item_id');
  580. $db_query->addExpression(':score', 'score', array(':score' => 1));
  581. }
  582. $filter = $query->getFilter();
  583. if ($filter->getFilters()) {
  584. $condition = $this->createFilterCondition($filter, $fields, $db_query);
  585. if ($condition) {
  586. $db_query->condition($condition);
  587. }
  588. }
  589. $db_query->addTag('search_api_db_search');
  590. $time_processing_done = microtime(TRUE);
  591. $results = array();
  592. $count_query = $db_query->countQuery();
  593. $results['result count'] = $count_query->execute()->fetchField();
  594. if ($results['result count']) {
  595. if ($query->getOption('search_api_facets')) {
  596. $results['search_api_facets'] = $this->getFacets($query, clone $db_query);
  597. }
  598. $query_options = $query->getOptions();
  599. if (isset($query_options['offset']) || isset($query_options['limit'])) {
  600. $offset = isset($query_options['offset']) ? $query_options['offset'] : 0;
  601. $limit = isset($query_options['limit']) ? $query_options['limit'] : 1000000;
  602. $db_query->range($offset, $limit);
  603. }
  604. $sort = $query->getSort();
  605. if ($sort) {
  606. foreach ($sort as $field_name => $order) {
  607. if ($order != 'ASC' && $order != 'DESC') {
  608. $msg = t('Unknown sort order @order. Assuming "ASC".', array('@order' => $order));
  609. $this->warnings[$msg] = $msg;
  610. $order = 'ASC';
  611. }
  612. if ($field_name == 'search_api_relevance') {
  613. $db_query->orderBy('score', $order);
  614. continue;
  615. }
  616. if ($field_name == 'search_api_id') {
  617. $db_query->orderBy('item_id', $order);
  618. continue;
  619. }
  620. if (!isset($fields[$field_name])) {
  621. throw new SearchApiException(t('Trying to sort on unknown field @field.', array('@field' => $field_name)));
  622. }
  623. $field = $fields[$field_name];
  624. if (search_api_is_list_type($field['type'])) {
  625. throw new SearchApiException(t('Cannot sort on field @field of a list type.', array('@field' => $field_name)));
  626. }
  627. if (search_api_is_text_type($field['type'])) {
  628. throw new SearchApiException(t('Cannot sort on fulltext field @field.', array('@field' => $field_name)));
  629. }
  630. $alias = $this->getTableAlias($field, $db_query);
  631. $db_query->orderBy($alias . '.value', $order);
  632. }
  633. }
  634. else {
  635. $db_query->orderBy('score', 'DESC');
  636. }
  637. $result = $db_query->execute();
  638. $time_queries_done = microtime(TRUE);
  639. foreach ($result as $row) {
  640. $results['results'][$row->item_id] = array(
  641. 'id' => $row->item_id,
  642. 'score' => $row->score,
  643. );
  644. }
  645. }
  646. else {
  647. $time_queries_done = microtime(TRUE);
  648. $results['results'] = array();
  649. }
  650. $results['warnings'] = array_keys($this->warnings);
  651. $results['ignored'] = array_keys($this->ignored);
  652. if ($set) {
  653. $this->resetDb();
  654. }
  655. $time_end = microtime(TRUE);
  656. $results['performance'] = array(
  657. 'complete' => $time_end - $time_method_called,
  658. 'preprocessing' => $time_processing_done - $time_method_called,
  659. 'execution' => $time_queries_done - $time_processing_done,
  660. 'postprocessing' => $time_end - $time_queries_done,
  661. );
  662. return $results;
  663. }
  664. /**
  665. * Helper method for removing unnecessary nested expressions from keys.
  666. */
  667. protected function prepareKeys($keys) {
  668. if (is_scalar($keys)) {
  669. $keys = $this->splitKeys($keys);
  670. return is_array($keys) ? $this->eliminateDuplicates($keys) : $keys;
  671. }
  672. elseif (!$keys) {
  673. return NULL;
  674. }
  675. $keys = $this->eliminateDuplicates($this->splitKeys($keys));
  676. $conj = $keys['#conjunction'];
  677. $neg = !empty($keys['#negation']);
  678. foreach ($keys as $i => &$nested) {
  679. if (is_array($nested)) {
  680. $nested = $this->prepareKeys($nested);
  681. if ($neg == !empty($nested['#negation'])) {
  682. if ($nested['#conjunction'] == $conj) {
  683. unset($nested['#conjunction'], $nested['#negation']);
  684. foreach ($nested as $renested) {
  685. $keys[] = $renested;
  686. }
  687. unset($keys[$i]);
  688. }
  689. }
  690. }
  691. }
  692. $keys = array_filter($keys);
  693. if (($count = count($keys)) <= 2) {
  694. if ($count < 2 || isset($keys['#negation'])) {
  695. $keys = NULL;
  696. }
  697. else {
  698. unset($keys['#conjunction']);
  699. $keys = array_shift($keys);
  700. }
  701. }
  702. return $keys;
  703. }
  704. /**
  705. * Helper method for splitting keys.
  706. */
  707. protected function splitKeys($keys) {
  708. if (is_scalar($keys)) {
  709. $proc = drupal_strtolower(trim($keys));
  710. if (is_numeric($proc)) {
  711. return ltrim($proc, '-0');
  712. }
  713. elseif (drupal_strlen($proc) < $this->options['min_chars']) {
  714. $this->ignored[$keys] = 1;
  715. return NULL;
  716. }
  717. $words = preg_split('/[^\p{L}\p{N}]+/u', $proc, -1, PREG_SPLIT_NO_EMPTY);
  718. if (count($words) > 1) {
  719. $proc = $this->splitKeys($words);
  720. $proc['#conjunction'] = 'AND';
  721. }
  722. return $proc;
  723. }
  724. foreach ($keys as $i => $key) {
  725. if (element_child($i)) {
  726. $keys[$i] = $this->splitKeys($key);
  727. }
  728. }
  729. return array_filter($keys);
  730. }
  731. /**
  732. * Helper method for eliminating duplicates from the search keys.
  733. */
  734. protected function eliminateDuplicates($keys, &$words = array()) {
  735. foreach ($keys as $i => $word) {
  736. if (!element_child($i)) {
  737. continue;
  738. }
  739. if (is_scalar($word)) {
  740. if (isset($words[$word])) {
  741. unset($keys[$i]);
  742. }
  743. else {
  744. $words[$word] = TRUE;
  745. }
  746. }
  747. else {
  748. $keys[$i] = $this->eliminateDuplicates($word, $words);
  749. }
  750. }
  751. return $keys;
  752. }
  753. /**
  754. * Helper method for creating a SELECT query for given search keys.
  755. *
  756. * @return SelectQueryInterface
  757. * A SELECT query returning item_id and score (or only item_id, if
  758. * $keys['#negation'] is set).
  759. */
  760. protected function createKeysQuery($keys, array $fields, array $all_fields) {
  761. if (!is_array($keys)) {
  762. $keys = array(
  763. '#conjunction' => 'AND',
  764. $keys,
  765. );
  766. }
  767. $or = db_or();
  768. $neg = !empty($keys['#negation']);
  769. $conj = $keys['#conjunction'];
  770. $words = array();
  771. $nested = array();
  772. $negated = array();
  773. $db_query = NULL;
  774. $mul_words = FALSE;
  775. $not_nested = FALSE;
  776. foreach ($keys as $i => $key) {
  777. if (!element_child($i)) {
  778. continue;
  779. }
  780. if (is_scalar($key)) {
  781. $words[] = $key;
  782. }
  783. elseif (empty($key['#negation'])) {
  784. if ($neg) {
  785. // If this query is negated, we also only need item_ids from
  786. // subqueries.
  787. $key['#negation'] = TRUE;
  788. }
  789. $nested[] = $key;
  790. }
  791. else {
  792. $negated[] = $key;
  793. }
  794. }
  795. $subs = count($words) + count($nested);
  796. $not_nested = ($subs <= 1 && count($fields) == 1) || ($neg && $conj == 'OR' && !$negated);
  797. if ($words) {
  798. if (count($words) > 1) {
  799. $mul_words = TRUE;
  800. foreach ($words as $word) {
  801. $or->condition('word', $word);
  802. }
  803. }
  804. else {
  805. $word = array_shift($words);
  806. }
  807. foreach ($fields as $name => $field) {
  808. $table = $field['table'];
  809. $query = db_select($table, 't', $this->query_options);
  810. if ($neg) {
  811. $query->fields('t', array('item_id'));
  812. }
  813. elseif ($not_nested) {
  814. $query->fields('t', array('item_id', 'score'));
  815. }
  816. else {
  817. $query->fields('t');
  818. }
  819. if ($mul_words) {
  820. $query->condition($or);
  821. }
  822. else {
  823. $query->condition('word', $word);
  824. }
  825. if (!isset($db_query)) {
  826. $db_query = $query;
  827. }
  828. elseif ($not_nested) {
  829. $db_query->union($query, 'UNION');
  830. }
  831. else {
  832. $db_query->union($query, 'UNION ALL');
  833. }
  834. }
  835. }
  836. if ($nested) {
  837. $word = '';
  838. foreach ($nested as $k) {
  839. $query = $this->createKeysQuery($k, $fields, $all_fields);
  840. if (!$neg) {
  841. $word .= ' ';
  842. $var = ':word' . strlen($word);
  843. $query->addExpression($var, 'word', array($var => $word));
  844. }
  845. if (!isset($db_query)) {
  846. $db_query = $query;
  847. }
  848. elseif ($not_nested) {
  849. $db_query->union($query, 'UNION');
  850. }
  851. else {
  852. $db_query->union($query, 'UNION ALL');
  853. }
  854. }
  855. }
  856. if (isset($db_query) && !$not_nested) {
  857. $db_query = db_select($db_query, 't', $this->query_options);
  858. $db_query->addField('t', 'item_id', 'item_id');
  859. if (!$neg) {
  860. $db_query->addExpression('SUM(t.score)', 'score');
  861. $db_query->groupBy('t.item_id');
  862. }
  863. if ($conj == 'AND' && $subs > 1) {
  864. $var = ':subs' . ((int) $subs);
  865. if (!$db_query->getGroupBy()) {
  866. $db_query->groupBy('t.item_id');
  867. }
  868. if ($mul_words) {
  869. $db_query->having('COUNT(DISTINCT t.word) >= ' . $var, array($var => $subs));
  870. }
  871. else {
  872. $db_query->having('COUNT(DISTINCT t.word) >= ' . $var, array($var => $subs));
  873. }
  874. }
  875. }
  876. if ($negated) {
  877. if (!isset($db_query) || $conj == 'OR') {
  878. if (isset($all_fields['search_api_language'])) {
  879. // We use this table because all items should be contained exactly once.
  880. $table = $all_fields['search_api_language']['table'];
  881. }
  882. else {
  883. $distinct = TRUE;
  884. foreach ($all_fields as $field) {
  885. $table = $field['table'];
  886. if (!search_api_is_list_type($field['type']) && !search_api_is_text_type($field['type'])) {
  887. unset($distinct);
  888. break;
  889. }
  890. }
  891. }
  892. if (isset($db_query)) {
  893. // We are in a rather bizarre case where the keys are something like "a OR (NOT b)".
  894. $old_query = $db_query;
  895. }
  896. $db_query = db_select($table, 't', $this->query_options);
  897. $db_query->addField('t', 'item_id', 'item_id');
  898. if (!$neg) {
  899. $db_query->addExpression(':score', 'score', array(':score' => 1));
  900. }
  901. if (isset($distinct)) {
  902. $db_query->distinct();
  903. }
  904. }
  905. if ($conj == 'AND') {
  906. foreach ($negated as $k) {
  907. $db_query->condition('t.item_id', $this->createKeysQuery($k, $fields, $all_fields), 'NOT IN');
  908. }
  909. }
  910. else {
  911. $or = db_or();
  912. foreach ($negated as $k) {
  913. $or->condition('t.item_id', $this->createKeysQuery($k, $fields, $all_fields), 'NOT IN');
  914. }
  915. if (isset($old_query)) {
  916. $or->condition('t.item_id', $old_query, 'NOT IN');
  917. }
  918. $db_query->condition($or);
  919. }
  920. }
  921. return $db_query;
  922. }
  923. /**
  924. * Helper method for finding any needed table for a filter query.
  925. */
  926. protected function findTable(array $filters, array $fields) {
  927. foreach ($filters as $filter) {
  928. if (is_array($filter)) {
  929. return $fields[$filter[0]]['table'];
  930. }
  931. }
  932. foreach ($filters as $filter) {
  933. if (is_object($filter)) {
  934. $ret = $this->findTable($filter->getFilters(), $fields);
  935. if ($ret) {
  936. return $ret;
  937. }
  938. }
  939. }
  940. }
  941. /**
  942. * Helper method for creating a condition for filtering search results.
  943. *
  944. * @return QueryConditionInterface
  945. */
  946. protected function createFilterCondition(SearchApiQueryFilterInterface $filter, array $fields, SelectQueryInterface $db_query) {
  947. $cond = db_condition($filter->getConjunction());
  948. $empty = TRUE;
  949. foreach ($filter->getFilters() as $f) {
  950. if (is_object($f)) {
  951. $c = $this->createFilterCondition($f, $fields, $db_query);
  952. if ($c) {
  953. $empty = FALSE;
  954. $cond->condition($c);
  955. }
  956. }
  957. else {
  958. $empty = FALSE;
  959. if (!isset($fields[$f[0]])) {
  960. throw new SearchApiException(t('Unknown field in filter clause: @field.', array('@field' => $f[0])));
  961. }
  962. if ($f[1] === NULL) {
  963. $query = db_select($fields[$f[0]]['table'], 't')
  964. ->fields('t', array('item_id'));
  965. $cond->condition('t.item_id', $query, $f[2] == '<>' || $f[2] == '!=' ? 'IN' : 'NOT IN');
  966. continue;
  967. }
  968. if (search_api_is_text_type($fields[$f[0]]['type'])) {
  969. // #negation is set here purely because we don't want any score.
  970. $keys = array('#conjunction' => 'AND', '#negation' => TRUE, $f[1]);
  971. $keys = $this->prepareKeys($keys);
  972. $query = $this->createKeysQuery($keys, array($fields[$f[0]]), $fields);
  973. $cond->condition('t.item_id', $query, $f[2] == '<>' || $f[2] == '!=' ? 'NOT IN' : 'IN');
  974. }
  975. else {
  976. $alias = $this->getTableAlias($fields[$f[0]], $db_query, search_api_is_list_type($fields[$f[0]]['type']));
  977. $cond->condition($alias . '.value', $f[1], $f[2]);
  978. }
  979. }
  980. }
  981. return $empty ? NULL : $cond;
  982. }
  983. /**
  984. * Helper method for adding a field's table to a database query.
  985. *
  986. * @param array $field
  987. * The field information array. The "table" key should contain the table
  988. * name to which a join should be made.
  989. * @param SelectQueryInterface $db_query
  990. * The database query used.
  991. * @param $newjoin
  992. * If TRUE, a join is done even if the table was already joined to in the
  993. * query.
  994. */
  995. protected function getTableAlias(array $field, SelectQueryInterface $db_query, $newjoin = FALSE) {
  996. if(!$newjoin) {
  997. foreach ($db_query->getTables() as $alias => $info) {
  998. $table = $info['table'];
  999. if (is_scalar($table) && $table == $field['table']) {
  1000. return $alias;
  1001. }
  1002. }
  1003. }
  1004. return $db_query->join($field['table'], 't', 't.item_id = %alias.item_id');
  1005. }
  1006. /**
  1007. * Helper method for getting the facet values for a query.
  1008. */
  1009. protected function getFacets(SearchApiQueryInterface $query, SelectQueryInterface $db_query) {
  1010. // We only need the id field, not the score.
  1011. $fields = &$db_query->getFields();
  1012. unset($fields['score']);
  1013. if (count($fields) != 1 || !isset($fields['item_id'])) {
  1014. $this->warnings[] = t('Error while adding facets: only "item_id" field should be used, used are: @fields.',
  1015. array('@fields' => implode(', ', array_keys($fields))));
  1016. return array();
  1017. }
  1018. $expressions = &$db_query->getExpressions();
  1019. $expressions = array();
  1020. $group_by = &$db_query->getGroupBy();
  1021. $group_by = array();
  1022. $db_query->distinct();
  1023. if (!$db_query->preExecute()) {
  1024. return array();
  1025. }
  1026. $args = $db_query->getArguments();
  1027. $table = db_query_temporary((string) $db_query, $args, $this->query_options);
  1028. $fields = $this->options['indexes'][$query->getIndex()->machine_name];
  1029. $ret = array();
  1030. foreach ($query->getOption('search_api_facets') as $key => $facet) {
  1031. if (empty($fields[$facet['field']])) {
  1032. $this->warnings[] = t('Unknown facet field @field.', array('@field' => $facet['field']));
  1033. continue;
  1034. }
  1035. $field = $fields[$facet['field']];
  1036. $missing_count = 0;
  1037. $select = db_select($table, 't');
  1038. $alias = $this->getTableAlias($field, $select, TRUE);
  1039. $select->addField($alias, search_api_is_text_type($field['type']) ? 'word' : 'value', 'value');
  1040. $select->addExpression('COUNT(DISTINCT t.item_id)', 'num');
  1041. $select->groupBy('value');
  1042. $select->orderBy('num', 'DESC');
  1043. $limit = $facet['limit'];
  1044. if ((int) $limit > 0) {
  1045. $select->range(0, $limit);
  1046. }
  1047. if ($facet['min_count'] > 1) {
  1048. $select->having('num >= :count', array(':count' => $facet['min_count']));
  1049. }
  1050. if (((bool) $facet['missing']) === search_api_is_list_type($field['type'])) {
  1051. // For multi-valued fields, the "missing" facet is defined by the table
  1052. // not having any entries for those items. We therefore need to execute
  1053. // an additional query, counting the items which do not have any entries
  1054. // in the field table.
  1055. if ($facet['missing']) {
  1056. $inner_query = db_select($field['table'], 't1')
  1057. ->fields('t1', array('item_id'));
  1058. $missing_count = db_select($table, 't');
  1059. $missing_count->addExpression('COUNT(item_id)');
  1060. $missing_count->condition('item_id', $inner_query, 'NOT IN');
  1061. $missing_count = $missing_count->execute()->fetchField();
  1062. $missing_count = $missing_count >= $facet['min_count'] ? $missing_count : 0;
  1063. }
  1064. // For single-valued fields, there are explicit NULL values in the
  1065. // table for "missing" entries. Therefore, we have to exclude NULL
  1066. // values if we do not want a "missing" facet.
  1067. else {
  1068. $select->isNotNull('value');
  1069. }
  1070. }
  1071. $terms = array();
  1072. foreach ($select->execute() as $row) {
  1073. if ($missing_count && $missing_count > $row->num) {
  1074. $terms[] = array(
  1075. 'count' => $missing_count,
  1076. 'filter' => '!',
  1077. );
  1078. $missing_count = 0;
  1079. if ($limit && count($terms) == $limit) {
  1080. break;
  1081. }
  1082. }
  1083. $terms[] = array(
  1084. 'count' => $row->num,
  1085. 'filter' => isset($row->value) ? '"' . $row->value . '"' : '!',
  1086. );
  1087. }
  1088. if ($missing_count && (!$limit || count($terms) < $limit)) {
  1089. $terms[] = array(
  1090. 'count' => $missing_count,
  1091. 'filter' => '!',
  1092. );
  1093. }
  1094. $ret[$key] = $terms;
  1095. }
  1096. return $ret;
  1097. }
  1098. /**
  1099. * Helper method for setting the database to the one selected by the user.
  1100. */
  1101. protected function setDb() {
  1102. if (!isset($this->previous_db)) {
  1103. list($key, $target) = explode(':', $this->options['database'], 2);
  1104. $this->previous_db = db_set_active($key);
  1105. if (!isset($this->query_options)) {
  1106. $this->query_options = array('target' => $target);
  1107. }
  1108. return TRUE;
  1109. }
  1110. return FALSE;
  1111. }
  1112. /**
  1113. * Helper method for resetting the original database.
  1114. */
  1115. protected function resetDb() {
  1116. if (isset($this->previous_db)) {
  1117. db_set_active($this->previous_db);
  1118. $this->previous_db = NULL;
  1119. return TRUE;
  1120. }
  1121. return FALSE;
  1122. }
  1123. }