service.inc 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644
  1. <?php
  2. /**
  3. * Search service class using Solr server.
  4. */
  5. class SearchApiSolrService extends SearchApiAbstractService {
  6. /**
  7. * The date format that Solr uses, in PHP date() syntax.
  8. */
  9. const SOLR_DATE_FORMAT = 'Y-m-d\TH:i:s\Z';
  10. /**
  11. * A connection to the Solr server.
  12. *
  13. * @var SearchApiSolrConnection
  14. */
  15. protected $solr;
  16. /**
  17. * @var array
  18. */
  19. protected $fieldNames = array();
  20. /**
  21. * Metadata describing fields on the Solr/Lucene index.
  22. *
  23. * @see SearchApiSolrService::getFields().
  24. *
  25. * @var array
  26. */
  27. protected $fields;
  28. /**
  29. * Saves whether a commit operation was already scheduled for this server.
  30. *
  31. * @var boolean
  32. */
  33. protected $commitScheduled = FALSE;
  34. /**
  35. * Request handler to use for this search query.
  36. *
  37. * @var string
  38. */
  39. protected $request_handler = NULL;
  40. public function __construct(SearchApiServer $server) {
  41. parent::__construct($server);
  42. }
  43. public function configurationForm(array $form, array &$form_state) {
  44. if ($this->options) {
  45. // Editing this server
  46. $url = 'http://' . $this->options['host'] . ':' . $this->options['port'] . $this->options['path'];
  47. $form['server_description'] = array(
  48. '#type' => 'item',
  49. '#title' => t('Solr server URI'),
  50. '#description' => l($url, $url),
  51. );
  52. }
  53. $options = $this->options + array(
  54. 'host' => 'localhost',
  55. 'port' => '8983',
  56. 'path' => '/solr',
  57. 'http_user' => '',
  58. 'http_pass' => '',
  59. 'excerpt' => FALSE,
  60. 'retrieve_data' => FALSE,
  61. 'highlight_data' => FALSE,
  62. 'http_method' => Apache_Solr_Service::METHOD_POST,
  63. 'autocorrect_spell' => TRUE,
  64. 'autocorrect_suggest_words' => TRUE,
  65. );
  66. $form['host'] = array(
  67. '#type' => 'textfield',
  68. '#title' => t('Solr host'),
  69. '#description' => t('The host name or IP of your Solr server, e.g. <code>localhost</code> or <code>www.example.com</code>.'),
  70. '#default_value' => $options['host'],
  71. '#required' => TRUE,
  72. );
  73. $form['port'] = array(
  74. '#type' => 'textfield',
  75. '#title' => t('Solr port'),
  76. '#description' => t('The Jetty example server is at port 8983, while Tomcat uses 8080 by default.'),
  77. '#default_value' => $options['port'],
  78. '#required' => TRUE,
  79. );
  80. $form['path'] = array(
  81. '#type' => 'textfield',
  82. '#title' => t('Solr path'),
  83. '#description' => t('The path that identifies the Solr instance to use on the server.'),
  84. '#default_value' => $options['path'],
  85. );
  86. $form['http'] = array(
  87. '#type' => 'fieldset',
  88. '#title' => t('Basic HTTP authentication'),
  89. '#description' => t('If your Solr server is protected by basic HTTP authentication, enter the login data here.'),
  90. '#collapsible' => TRUE,
  91. '#collapsed' => empty($options['http_user']),
  92. );
  93. $form['http']['http_user'] = array(
  94. '#type' => 'textfield',
  95. '#title' => t('Username'),
  96. '#default_value' => $options['http_user'],
  97. );
  98. $form['http']['http_pass'] = array(
  99. '#type' => 'password',
  100. '#title' => t('Password'),
  101. '#default_value' => $options['http_pass'],
  102. );
  103. $form['advanced'] = array(
  104. '#type' => 'fieldset',
  105. '#title' => t('Advanced'),
  106. '#collapsible' => TRUE,
  107. '#collapsed' => TRUE,
  108. );
  109. $form['advanced']['excerpt'] = array(
  110. '#type' => 'checkbox',
  111. '#title' => t('Return an excerpt for all results'),
  112. '#description' => t("If search keywords are given, use Solr's capabilities to create a highlighted search excerpt for each result. " .
  113. 'Whether the excerpts will actually be displayed depends on the settings of the search, though.'),
  114. '#default_value' => $options['excerpt'],
  115. );
  116. $form['advanced']['retrieve_data'] = array(
  117. '#type' => 'checkbox',
  118. '#title' => t('Retrieve result data from Solr'),
  119. '#description' => t('When checked, result data will be retrieved directly from the Solr server. ' .
  120. 'This might make item loads unnecessary. Only indexed fields can be retrieved. ' .
  121. 'Note also that the returned field data might not always be correct, due to preprocessing and caching issues.'),
  122. '#default_value' => $options['retrieve_data'],
  123. );
  124. $form['advanced']['highlight_data'] = array(
  125. '#type' => 'checkbox',
  126. '#title' => t('Highlight retrieved data'),
  127. '#description' => t('When retrieving result data from the Solr server, try to highlight the search terms in the returned fulltext fields.'),
  128. '#default_value' => $options['highlight_data'],
  129. );
  130. // Highlighting retrieved data only makes sense when we retrieve data.
  131. // (Actually, internally it doesn't really matter. However, from a user's
  132. // perspective, having to check both probably makes sense.)
  133. $form['advanced']['highlight_data']['#states']['invisible']
  134. [':input[name="options[form][advanced][retrieve_data]"]']['checked'] = FALSE;
  135. $form['advanced']['http_method'] = array(
  136. '#type' => 'select',
  137. '#title' => t('HTTP method'),
  138. '#description' => t('The HTTP method to use for sending queries. Usually, POST will work fine in all cases.'),
  139. '#default_value' => $options['http_method'],
  140. '#options' => array(
  141. Apache_Solr_Service::METHOD_POST => 'POST',
  142. Apache_Solr_Service::METHOD_GET => 'GET',
  143. ),
  144. );
  145. if (module_exists('search_api_autocomplete')) {
  146. $form['advanced']['autocomplete'] = array(
  147. '#type' => 'fieldset',
  148. '#title' => t('Autocomplete'),
  149. '#collapsible' => TRUE,
  150. '#collapsed' => TRUE,
  151. );
  152. $form['advanced']['autocomplete']['autocorrect_spell'] = array(
  153. '#type' => 'checkbox',
  154. '#title' => t('Use spellcheck for autocomplete suggestions'),
  155. '#description' => t('If activated, spellcheck suggestions ("Did you mean") will be included in the autocomplete suggestions. Since the used dictionary contains words from all indexes, this might lead to leaking of sensitive data, depending on your setup.'),
  156. '#default_value' => $options['autocorrect_spell'],
  157. );
  158. $form['advanced']['autocomplete']['autocorrect_suggest_words'] = array(
  159. '#type' => 'checkbox',
  160. '#title' => t('Suggest additional words'),
  161. '#description' => t('If activated and the user enters a complete word, Solr will suggest additional words the user wants to search, which are often found (not searched!) together. This has been known to lead to strange results in some configurations – if you see inappropriate additional-word suggestions, you might want to deactivate this option.'),
  162. '#default_value' => $options['autocorrect_suggest_words'],
  163. );
  164. }
  165. return $form;
  166. }
  167. public function configurationFormValidate(array $form, array &$values, array &$form_state) {
  168. if (isset($values['port']) && (!is_numeric($values['port']) || $values['port'] < 0 || $values['port'] > 65535)) {
  169. form_error($form['port'], t('The port has to be an integer between 0 and 65535.'));
  170. }
  171. }
  172. public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
  173. // Since the form is nested into another, we can't simply use #parents for
  174. // doing this array restructuring magic. (At least not without creating an
  175. // unnecessary dependency on internal implementation.)
  176. $values += $values['http'];
  177. $values += $values['advanced'];
  178. $values += !empty($values['autocomplete']) ? $values['autocomplete'] : array();
  179. unset($values['http'], $values['advanced'], $values['autocomplete']);
  180. // Highlighting retrieved data only makes sense when we retrieve data.
  181. $values['highlight_data'] &= $values['retrieve_data'];
  182. parent::configurationFormSubmit($form, $values, $form_state);
  183. }
  184. public function supportsFeature($feature) {
  185. // Search API features.
  186. $supported = array(
  187. 'search_api_autocomplete',
  188. 'search_api_facets',
  189. 'search_api_facets_operator_or',
  190. 'search_api_mlt',
  191. 'search_api_multi',
  192. 'search_api_spellcheck',
  193. 'search_api_data_type_location',
  194. 'search_api_data_type_geohash',
  195. );
  196. // Custom data types.
  197. foreach (search_api_solr_get_dynamic_field_info() as $type => $info) {
  198. $supported[] = 'search_api_data_type_' . $type;
  199. }
  200. $supported = drupal_map_assoc($supported);
  201. return isset($supported[$feature]);
  202. }
  203. /**
  204. * View this server's settings.
  205. */
  206. public function viewSettings() {
  207. $output = '';
  208. $options = $this->options;
  209. $url = 'http://' . $options['host'] . ':' . $options['port'] . $options['path'];
  210. $output .= "<dl>\n <dt>";
  211. $output .= t('Solr server URI');
  212. $output .= "</dt>\n <dd>";
  213. $output .= l($url, $url);
  214. $output .= '</dd>';
  215. if ($options['http_user']) {
  216. $output .= "\n <dt>";
  217. $output .= t('Basic HTTP authentication');
  218. $output .= "</dt>\n <dd>";
  219. $output .= t('Username: @user', array('@user' => $options['http_user']));
  220. $output .= "</dd>\n <dd>";
  221. $output .= t('Password: @pass', array('@pass' => str_repeat('*', strlen($options['http_pass']))));
  222. $output .= '</dd>';
  223. }
  224. $output .= "\n</dl>";
  225. return $output;
  226. }
  227. /**
  228. * Create a connection to the Solr server as configured in $this->options.
  229. */
  230. protected function connect() {
  231. if (!$this->solr) {
  232. if (!class_exists('Apache_Solr_Service')) {
  233. throw new Exception(t('SolrPhpClient library not found! Please follow the instructions in search_api_solr/INSTALL.txt for installing the Solr search module.'));
  234. }
  235. $this->solr = new SearchApiSolrConnection($this->options);
  236. }
  237. }
  238. public function addIndex(SearchApiIndex $index) {
  239. if (module_exists('search_api_multi') && module_exists('search_api_views')) {
  240. views_invalidate_cache();
  241. }
  242. }
  243. public function fieldsUpdated(SearchApiIndex $index) {
  244. if (module_exists('search_api_multi') && module_exists('search_api_views')) {
  245. views_invalidate_cache();
  246. }
  247. return TRUE;
  248. }
  249. public function removeIndex($index) {
  250. if (module_exists('search_api_multi') && module_exists('search_api_views')) {
  251. views_invalidate_cache();
  252. }
  253. $id = is_object($index) ? $index->machine_name : $index;
  254. // Only delete the index's data if the index isn't read-only.
  255. if (!is_object($index) || empty($index->read_only)) {
  256. try {
  257. $this->connect();
  258. $this->solr->deleteByQuery("index_id:" . $id);
  259. }
  260. catch (Exception $e) {
  261. watchdog_exception('search_api_solr', $e, "%type while deleting an index's data: !message in %function (line %line of %file).");
  262. }
  263. }
  264. }
  265. public function indexItems(SearchApiIndex $index, array $items) {
  266. $documents = array();
  267. $ret = array();
  268. $index_id = $index->machine_name;
  269. $fields = $this->getFieldNames($index);
  270. foreach ($items as $id => $item) {
  271. try {
  272. $doc = new Apache_Solr_Document();
  273. $doc->setField('id', $this->createId($index_id, $id));
  274. $doc->setField('index_id', $index_id);
  275. $doc->setField('item_id', $id);
  276. foreach ($item as $key => $field) {
  277. if (!isset($fields[$key])) {
  278. throw new SearchApiException(t('Unknown field @field.', array('@field' => $key)));
  279. }
  280. $this->addIndexField($doc, $fields[$key], $field['value'], $field['type']);
  281. }
  282. $documents[] = $doc;
  283. $ret[] = $id;
  284. }
  285. catch (Exception $e) {
  286. watchdog_exception('search_api_solr', $e, "%type while indexing @type with ID @id: !message in %function (line %line of %file).", array('@type' => $index->item_type, '@id' => $id), WATCHDOG_WARNING);
  287. }
  288. }
  289. if (!$documents) {
  290. return array();
  291. }
  292. try {
  293. $this->connect();
  294. $response = $this->solr->addDocuments($documents);
  295. if ($response->getHttpStatus() == 200) {
  296. if (!empty($index->options['index_directly'])) {
  297. $this->scheduleCommit();
  298. }
  299. return $ret;
  300. }
  301. throw new SearchApiException(t('HTTP status @status: @msg.',
  302. array('@status' => $response->getHttpStatus(), '@msg' => $response->getHttpStatusMessage())));
  303. }
  304. catch (Exception $e) {
  305. watchdog_exception('search_api_solr', $e, "%type while indexing: !message in %function (line %line of %file).");
  306. }
  307. return array();
  308. }
  309. /**
  310. * Creates an ID used as the unique identifier at the Solr server. This has to
  311. * consist of both index and item ID.
  312. */
  313. protected function createId($index_id, $item_id) {
  314. return "$index_id-$item_id";
  315. }
  316. /**
  317. * Create a list of all indexed field names mapped to their Solr field names.
  318. *
  319. * The special fields "search_api_id", "search_api_relevance", and "id" are
  320. * also included. Any Solr fields that exist on search results are mapped back
  321. * to their local field names in the final result set.
  322. *
  323. * @see SearchApiSolrService::search()
  324. */
  325. public function getFieldNames(SearchApiIndex $index, $reset = FALSE) {
  326. if (!isset($this->fieldNames[$index->machine_name]) || $reset) {
  327. // This array maps "local property name" => "solr doc property name".
  328. $ret = array(
  329. 'search_api_id' => 'ss_search_api_id',
  330. 'search_api_relevance' => 'score',
  331. 'search_api_item_id' => 'item_id',
  332. );
  333. // Add the names of any fields configured on the index.
  334. $fields = (isset($index->options['fields']) ? $index->options['fields'] : array());
  335. foreach ($fields as $key => $field) {
  336. // Generate a field name; this corresponds with naming conventions in
  337. // our schema.xml
  338. $type = $field['type'];
  339. // Use the real type of the field if the server supports this type.
  340. if (isset($field['real_type'])) {
  341. $custom_type = search_api_extract_inner_type($field['real_type']);
  342. if ($this->supportsFeature('search_api_data_type_' . $custom_type)) {
  343. $type = $field['real_type'];
  344. }
  345. }
  346. $inner_type = search_api_extract_inner_type($type);
  347. $type_info = search_api_solr_get_dynamic_field_info($inner_type);
  348. $pref = isset($type_info['prefix']) ? $type_info['prefix']: '';
  349. if (empty($type_info['always multiValued'])) {
  350. $pref .= $type == $inner_type ? 's' : 'm';
  351. }
  352. $name = $pref . '_' . $key;
  353. $ret[$key] = $name;
  354. }
  355. // Let modules adjust the field mappings.
  356. drupal_alter('search_api_solr_field_mapping', $index, $ret);
  357. $this->fieldNames[$index->machine_name] = $ret;
  358. }
  359. return $this->fieldNames[$index->machine_name];
  360. }
  361. /**
  362. * Helper method for indexing.
  363. * Add $field with field name $key to the document $doc. The format of $field
  364. * is the same as specified in SearchApiServiceInterface::indexItems().
  365. */
  366. protected function addIndexField(Apache_Solr_Document $doc, $key, $value, $type, $multi_valued = FALSE) {
  367. // Don't index empty values (i.e., when field is missing)
  368. if (!isset($value)) {
  369. return;
  370. }
  371. if (search_api_is_list_type($type)) {
  372. $type = substr($type, 5, -1);
  373. foreach ($value as $v) {
  374. $this->addIndexField($doc, $key, $v, $type, TRUE);
  375. }
  376. return;
  377. }
  378. switch ($type) {
  379. case 'tokens':
  380. foreach ($value as $v) {
  381. $doc->addField($key, $v['value']);
  382. }
  383. return;
  384. case 'boolean':
  385. $value = $value ? 'true' : 'false';
  386. break;
  387. case 'date':
  388. $value = is_numeric($value) ? (int) $value : strtotime($value);
  389. if ($value === FALSE) {
  390. return;
  391. }
  392. $value = format_date($value, 'custom', self::SOLR_DATE_FORMAT, 'UTC');
  393. break;
  394. case 'integer':
  395. $value = (int) $value;
  396. break;
  397. case 'decimal':
  398. $value = (float) $value;
  399. break;
  400. }
  401. if ($multi_valued) {
  402. $doc->addField($key, $value);
  403. }
  404. else {
  405. $doc->setField($key, $value);
  406. }
  407. }
  408. /**
  409. * Delete items from an index on this server.
  410. *
  411. * This method has a custom, Solr-specific extension:
  412. * If $ids is a string other than "all", it is treated as a Solr query. All
  413. * items matching that Solr query are then deleted. If $index is additionally
  414. * specified, then only those items also lying on that index will be deleted.
  415. * It is up to the caller to ensure $ids is a valid query when the method is
  416. * called in this fashion.
  417. */
  418. public function deleteItems($ids = 'all', SearchApiIndex $index = NULL) {
  419. try {
  420. $this->connect();
  421. if ($index) {
  422. $index_id = $index->machine_name;
  423. if (is_array($ids)) {
  424. $solr_ids = array();
  425. foreach ($ids as $id) {
  426. $solr_ids[] = $this->createId($index_id, $id);
  427. }
  428. $this->solr->deleteByMultipleIds($solr_ids);
  429. }
  430. elseif ($ids == 'all') {
  431. $this->solr->deleteByQuery("index_id:" . $index_id);
  432. }
  433. else {
  434. $this->solr->deleteByQuery("index_id:" . $index_id . ' (' . $ids . ')');
  435. }
  436. }
  437. else {
  438. $q = $ids == 'all' ? '*:*' : $ids;
  439. $this->solr->deleteByQuery($q);
  440. }
  441. $this->scheduleCommit();
  442. }
  443. catch(Exception $e) {
  444. watchdog_exception('search_api_solr', $e, '%type while deleting items from server @server: !message in %function (line %line of %file).', array('@server' => $this->server->name));
  445. }
  446. }
  447. public function search(SearchApiQueryInterface $query) {
  448. $time_method_called = microtime(TRUE);
  449. // Reset request handler
  450. $this->request_handler = NULL;
  451. // Get field information
  452. $index = $query->getIndex();
  453. $fields = $this->getFieldNames($index);
  454. // Extract keys
  455. $keys = $query->getKeys();
  456. if (is_array($keys)) {
  457. $keys = $this->flattenKeys($keys);
  458. }
  459. // Set searched fields
  460. $options = $query->getOptions();
  461. $search_fields = $query->getFields();
  462. // Get the index fields to be able to retrieve boosts.
  463. $index_fields = $index->getFields();
  464. $qf = array();
  465. foreach ($search_fields as $f) {
  466. $boost = '';
  467. $boost = isset($index_fields[$f]['boost']) ? '^' . $index_fields[$f]['boost'] : '';
  468. $qf[] = $fields[$f] . $boost;
  469. }
  470. // Extract filters
  471. $filter = $query->getFilter();
  472. $fq = $this->createFilterQueries($filter, $fields, $index->options['fields']);
  473. $fq[] = 'index_id:' . $index->machine_name;
  474. // Extract sort
  475. $sort = array();
  476. foreach ($query->getSort() as $f => $order) {
  477. $f = $fields[$f];
  478. if (substr($f, 0, 3) == 'ss_') {
  479. $f = 'sort_' . substr($f, 3);
  480. }
  481. $order = strtolower($order);
  482. $sort[] = "$f $order";
  483. }
  484. // Get facet fields
  485. $facets = $query->getOption('search_api_facets', array());
  486. $facet_params = $this->getFacetParams($facets, $fields, $fq);
  487. // Handle highlighting
  488. $highlight_params = $this->getHighlightParams($query);
  489. // Handle More Like This query
  490. $mlt = $query->getOption('search_api_mlt');
  491. if ($mlt) {
  492. $mlt_params['qt'] = 'mlt';
  493. // The fields to look for similarities in.
  494. $mlt_fl = array();
  495. foreach($mlt['fields'] as $f) {
  496. $mlt_fl[] = $fields[$f];
  497. // For non-text fields, set minimum word length to 0.
  498. if (isset($index->options['fields'][$f]['type']) && !search_api_is_text_type($index->options['fields'][$f]['type'])) {
  499. $mlt_params['f.' . $fields[$f] . '.mlt.minwl'] = 0;
  500. }
  501. }
  502. $mlt_params['mlt.fl'] = implode(',', $mlt_fl);
  503. $keys = 'id:' . SearchApiSolrConnection::phrase($this->createId($index->machine_name, $mlt['id']));
  504. }
  505. // Set defaults
  506. if (!$keys) {
  507. $keys = NULL;
  508. }
  509. $offset = isset($options['offset']) ? $options['offset'] : 0;
  510. $limit = isset($options['limit']) ? $options['limit'] : 1000000;
  511. // Collect parameters
  512. $params = array(
  513. 'fl' => 'item_id,score',
  514. 'qf' => $qf,
  515. 'fq' => $fq,
  516. );
  517. if ($sort) {
  518. $params['sort'] = implode(', ', $sort);
  519. }
  520. if (!empty($facet_params['facet.field'])) {
  521. $params += $facet_params;
  522. }
  523. if (!empty($highlight_params)) {
  524. $params += $highlight_params;
  525. }
  526. if (!empty($options['search_api_spellcheck'])) {
  527. $params['spellcheck'] = 'true';
  528. }
  529. if (!empty($mlt_params['mlt.fl'])) {
  530. $params += $mlt_params;
  531. }
  532. if (!empty($this->options['retrieve_data'])) {
  533. $params['fl'] = '*,score';
  534. }
  535. $call_args = array(
  536. 'query' => &$keys,
  537. 'offset' => &$offset,
  538. 'limit' => &$limit,
  539. 'params' => &$params,
  540. );
  541. if ($this->request_handler) {
  542. $this->setRequestHandler($this->request_handler, $call_args);
  543. }
  544. try {
  545. // Send search request
  546. $time_processing_done = microtime(TRUE);
  547. $this->connect();
  548. drupal_alter('search_api_solr_query', $call_args, $query);
  549. $this->preQuery($call_args, $query);
  550. // Retrieve http method from server options.
  551. $http_method = !empty($this->options['http_method']) ? $this->options['http_method'] : Apache_Solr_Service::METHOD_POST;
  552. $response = $this->solr->search($keys, $offset, $limit, $params, $http_method);
  553. $time_query_done = microtime(TRUE);
  554. if ($response->getHttpStatus() != 200) {
  555. throw new SearchApiException(t('The Solr server responded with status code @status: @msg.',
  556. array('@status' => $response->getHttpStatus(), '@msg' => $response->getHttpStatusMessage())));
  557. }
  558. // Extract results
  559. $results = $this->extractResults($query, $response);
  560. // Extract facets
  561. if ($facets = $this->extractFacets($query, $response)) {
  562. $results['search_api_facets'] = $facets;
  563. }
  564. drupal_alter('search_api_solr_search_results', $results, $query, $response);
  565. $this->postQuery($results, $query, $response);
  566. // Compute performance
  567. $time_end = microtime(TRUE);
  568. $results['performance'] = array(
  569. 'complete' => $time_end - $time_method_called,
  570. 'preprocessing' => $time_processing_done - $time_method_called,
  571. 'execution' => $time_query_done - $time_processing_done,
  572. 'postprocessing' => $time_end - $time_query_done,
  573. );
  574. return $results;
  575. }
  576. catch (Exception $e) {
  577. throw new SearchApiException(t('An error occurred while trying to search with Solr: @msg.', array('@msg' => $e->getMessage())));
  578. }
  579. }
  580. /**
  581. * Extract results from a Solr response.
  582. *
  583. * @param Apache_Solr_Response $response
  584. * A response object from SolrPhpClient.
  585. *
  586. * @return array
  587. * An array with two keys:
  588. * - result count: The number of total results.
  589. * - results: An array of search results, as specified by
  590. * SearchApiQueryInterface::execute().
  591. */
  592. protected function extractResults(SearchApiQueryInterface $query, Apache_Solr_Response $response) {
  593. $index = $query->getIndex();
  594. $fields = $this->getFieldNames($index);
  595. $field_options = $index->options['fields'];
  596. // Set up the results array.
  597. $results = array();
  598. $results['results'] = array();
  599. // In some rare cases (e.g., MLT query with nonexistent ID) the response
  600. // will be NULL.
  601. if (!isset($response->response)) {
  602. $results['result count'] = 0;
  603. return $results;
  604. }
  605. $results['result count'] = $response->response->numFound;
  606. // Add each search result to the results array.
  607. foreach ($response->response->docs as $doc) {
  608. // Blank result array.
  609. $result = array(
  610. 'id' => NULL,
  611. 'score' => NULL,
  612. 'fields' => array(),
  613. );
  614. // Extract properties from the Solr document, translating from Solr to
  615. // Search API property names. This reverses the mapping in
  616. // SearchApiSolrService::getFieldNames().
  617. foreach ($fields as $search_api_property => $solr_property) {
  618. if (isset($doc->{$solr_property})) {
  619. $result['fields'][$search_api_property] = $doc->{$solr_property};
  620. // Date fields need some special treatment to become valid date values
  621. // (i.e., timestamps) again.
  622. if (isset($field_options[$search_api_property]['type'])
  623. && $field_options[$search_api_property]['type'] == 'date'
  624. && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $result['fields'][$search_api_property])) {
  625. $result['fields'][$search_api_property] = strtotime($result['fields'][$search_api_property]);
  626. }
  627. }
  628. }
  629. // We can find the item id and score in the special 'search_api_*'
  630. // properties. Mappings are provided for these properties in
  631. // SearchApiSolrService::getFieldNames().
  632. $result['id'] = $result['fields']['search_api_item_id'];
  633. $result['score'] = $result['fields']['search_api_relevance'];
  634. $solr_id = $this->createId($index->machine_name, $result['id']);
  635. $excerpt = $this->getExcerpt($response, $solr_id, $result['fields'], $fields);
  636. if ($excerpt) {
  637. $result['excerpt'] = $excerpt;
  638. }
  639. // Use the result's id as the array key. By default, 'id' is mapped to
  640. // 'item_id' in SearchApiSolrService::getFieldNames().
  641. if ($result['id']) {
  642. $results['results'][$result['id']] = $result;
  643. }
  644. }
  645. // Check for spellcheck suggestions.
  646. if (module_exists('search_api_spellcheck') && $query->getOption('search_api_spellcheck')) {
  647. $results['search_api_spellcheck'] = new SearchApiSpellcheckSolr($response);
  648. }
  649. return $results;
  650. }
  651. /**
  652. * Extract and format highlighting information for a specific item from a Solr response.
  653. *
  654. * Will also use highlighted fields to replace retrieved field data, if the
  655. * corresponding option is set.
  656. */
  657. protected function getExcerpt(Apache_Solr_Response $response, $id, array &$fields, array $field_mapping) {
  658. if (!isset($response->highlighting->$id)) {
  659. return FALSE;
  660. }
  661. $output = '';
  662. if (!empty($this->options['excerpt']) && !empty($response->highlighting->$id->spell)) {
  663. foreach ($response->highlighting->$id->spell as $snippet) {
  664. $snippet = strip_tags($snippet);
  665. $snippet = preg_replace('/^.*>|<.*$/', '', $snippet);
  666. $snippet = $this->formatHighlighting($snippet);
  667. // The created fragments sometimes have leading or trailing punctuation.
  668. // We remove that here for all common cases, but take care not to remove
  669. // < or > (so HTML tags stay valid).
  670. $snippet = trim($snippet, "\00..\x2F:;=\x3F..\x40\x5B..\x60");
  671. $output .= $snippet . ' … ';
  672. }
  673. }
  674. if (!empty($this->options['highlight_data'])) {
  675. foreach ($field_mapping as $search_api_property => $solr_property) {
  676. if (substr($solr_property, 0, 2) == 't_' && !empty($response->highlighting->$id->$solr_property)) {
  677. // Contrary to above, we here want to preserve HTML, so we just
  678. // replace the [HIGHLIGHT] tags with the appropriate format here.
  679. $fields[$search_api_property] = $this->formatHighlighting($response->highlighting->$id->$solr_property);
  680. }
  681. }
  682. }
  683. return $output;
  684. }
  685. protected function formatHighlighting($snippet) {
  686. return preg_replace('#\[(/?)HIGHLIGHT\]#', '<$1strong>', $snippet);
  687. }
  688. /**
  689. * Extract facets from a Solr response.
  690. *
  691. * @param Apache_Solr_Response $response
  692. * A response object from SolrPhpClient.
  693. *
  694. * @return array
  695. * An array describing facets that apply to the current results.
  696. */
  697. protected function extractFacets(SearchApiQueryInterface $query, Apache_Solr_Response $response) {
  698. if (isset($response->facet_counts->facet_fields)) {
  699. $index = $query->getIndex();
  700. $fields = $this->getFieldNames($index);
  701. $facets = array();
  702. $facet_fields = $response->facet_counts->facet_fields;
  703. $extract_facets = $query->getOption('search_api_facets');
  704. $extract_facets = ($extract_facets ? $extract_facets : array());
  705. foreach ($extract_facets as $delta => $info) {
  706. $field = $fields[$info['field']];
  707. if (!empty($facet_fields->$field)) {
  708. $min_count = $info['min_count'];
  709. $terms = $facet_fields->$field;
  710. if ($info['missing']) {
  711. // We have to correctly incorporate the "_empty_" term.
  712. // This will ensure that the term with the least results is dropped, if the limit would be exceeded.
  713. if (isset($terms->_empty_) && $terms->_empty_ < $min_count) {
  714. unset($terms->_empty_);
  715. }
  716. else {
  717. $terms = (array) $terms;
  718. arsort($terms);
  719. if (count($terms) > $info['limit']) {
  720. array_pop($terms);
  721. }
  722. }
  723. }
  724. elseif (isset($terms->_empty_)) {
  725. $terms = clone $terms;
  726. unset($terms->_empty_);
  727. }
  728. $type = isset($index->options['fields'][$info['field']]['type']) ? $index->options['fields'][$info['field']]['type'] : 'string';
  729. foreach ($terms as $term => $count) {
  730. if ($count >= $min_count) {
  731. if ($type == 'boolean') {
  732. if ($term == 'true') {
  733. $term = 1;
  734. }
  735. elseif ($term == 'false') {
  736. $term = 0;
  737. }
  738. }
  739. elseif ($type == 'date') {
  740. $term = isset($term) ? strtotime($term) : NULL;
  741. }
  742. $term = $term === '_empty_' ? '!' : '"' . $term . '"';
  743. $facets[$delta][] = array(
  744. 'filter' => $term,
  745. 'count' => $count,
  746. );
  747. }
  748. }
  749. if (empty($facets[$delta])) {
  750. unset($facets[$delta]);
  751. }
  752. }
  753. }
  754. return $facets;
  755. }
  756. }
  757. /**
  758. * Flatten a keys array into a single search string.
  759. *
  760. * @param array $keys
  761. * The keys array to flatten, formatted as specified by
  762. * SearchApiQueryInterface::getKeys().
  763. *
  764. * @return string
  765. * A Solr query string representing the same keys.
  766. */
  767. protected function flattenKeys(array $keys) {
  768. $k = array();
  769. $or = $keys['#conjunction'] == 'OR';
  770. $neg = !empty($keys['#negation']);
  771. foreach (element_children($keys) as $i) {
  772. $key = $keys[$i];
  773. if (!$key) {
  774. continue;
  775. }
  776. if (is_array($key)) {
  777. $subkeys = $this->flattenKeys($key);
  778. if ($subkeys) {
  779. $nested_expressions = TRUE;
  780. // If this is a negated OR expression, we can't just use nested keys
  781. // as-is, but have to put them into parantheses.
  782. if ($or && $neg) {
  783. $subkeys = "($subkeys)";
  784. }
  785. $k[] = $subkeys;
  786. }
  787. }
  788. else {
  789. $key = trim($key);
  790. $key = SearchApiSolrConnection::phrase($key);
  791. $k[] = $key;
  792. }
  793. }
  794. if (!$k) {
  795. return '';
  796. }
  797. // Formatting the keys into a Solr query can be a bit complex. The following
  798. // code will produce that look like this:
  799. //
  800. // #conjunction | #negation | return value
  801. // ----------------------------------------------------------------
  802. // AND | FALSE | A B C
  803. // AND | TRUE | -(A B C)
  804. // OR | FALSE | ((A) OR (B) OR (C))
  805. // OR | TRUE | -A -B -C
  806. // If there was just a single, unnested key, we can ignore all this.
  807. if (count($k) == 1 && empty($nested_expressions)) {
  808. $k = reset($k);
  809. return $neg ? "-$k" : $k;
  810. }
  811. if ($or) {
  812. if ($neg) {
  813. return '-' . implode(' -', $k);
  814. }
  815. return '((' . implode(') OR (', $k) . '))';
  816. }
  817. $k = implode(' ', $k);
  818. return $neg ? "-($k)" : $k;
  819. }
  820. /**
  821. * Transforms a query filter into a flat array of Solr filter queries, using
  822. * the field names in $fields.
  823. */
  824. protected function createFilterQueries(SearchApiQueryFilterInterface $filter, array $solr_fields, array $fields) {
  825. $or = $filter->getConjunction() == 'OR';
  826. $fq = array();
  827. foreach ($filter->getFilters() as $f) {
  828. if (is_array($f)) {
  829. if (!isset($fields[$f[0]])) {
  830. throw new SearchApiException(t('Filter term on unknown or unindexed field @field.', array('@field' => $f[0])));
  831. }
  832. if ($f[1] !== '') {
  833. $fq[] = $this->createFilterQuery($solr_fields[$f[0]], $f[1], $f[2], $fields[$f[0]]);
  834. }
  835. }
  836. else {
  837. $q = $this->createFilterQueries($f, $solr_fields, $fields);
  838. if ($filter->getConjunction() != $f->getConjunction()) {
  839. // $or == TRUE means the nested filter has conjunction AND, and vice versa
  840. $sep = $or ? ' ' : ' OR ';
  841. $fq[] = count($q) == 1 ? reset($q) : '((' . implode(')' . $sep . '(', $q) . '))';
  842. }
  843. else {
  844. $fq = array_merge($fq, $q);
  845. }
  846. }
  847. }
  848. return ($or && count($fq) > 1) ? array('((' . implode(') OR (', $fq) . '))') : $fq;
  849. }
  850. /**
  851. * Create a single search query string according to the given field, value
  852. * and operator.
  853. */
  854. protected function createFilterQuery($field, $value, $operator, $field_info) {
  855. $field = SearchApiSolrConnection::escapeFieldName($field);
  856. if ($value === NULL) {
  857. return ($operator == '=' ? '-' : '') . "$field:[* TO *]";
  858. }
  859. $value = trim($value);
  860. $value = $this->formatFilterValue($value, search_api_extract_inner_type($field_info['type']));
  861. switch ($operator) {
  862. case '<>':
  863. return "-($field:$value)";
  864. case '<':
  865. return "$field:{* TO $value}";
  866. case '<=':
  867. return "$field:[* TO $value]";
  868. case '>=':
  869. return "$field:[$value TO *]";
  870. case '>':
  871. return "$field:{{$value} TO *}";
  872. default:
  873. return "$field:$value";
  874. }
  875. }
  876. /**
  877. * Format a value for filtering on a field of a specific type.
  878. */
  879. protected function formatFilterValue($value, $type) {
  880. switch ($type) {
  881. case 'boolean':
  882. $value = $value ? 'true' : 'false';
  883. break;
  884. case 'date':
  885. $value = is_numeric($value) ? (int) $value : strtotime($value);
  886. if ($value === FALSE) {
  887. return 0;
  888. }
  889. $value = format_date($value, 'custom', self::SOLR_DATE_FORMAT, 'UTC');
  890. break;
  891. }
  892. return SearchApiSolrConnection::phrase($value);
  893. }
  894. /**
  895. * Helper method for creating the facet field parameters.
  896. */
  897. protected function getFacetParams(array $facets, array $fields, array &$fq = array()) {
  898. if (!$facets) {
  899. return array();
  900. }
  901. $facet_params['facet'] = 'true';
  902. $facet_params['facet.sort'] = 'count';
  903. $facet_params['facet.limit'] = 10;
  904. $facet_params['facet.mincount'] = 1;
  905. $facet_params['facet.missing'] = 'false';
  906. $taggedFields = array();
  907. foreach ($facets as $info) {
  908. if (empty($fields[$info['field']])) {
  909. continue;
  910. }
  911. // String fields have their own corresponding facet fields.
  912. $field = $fields[$info['field']];
  913. // Check for the "or" operator.
  914. if (isset($info['operator']) && $info['operator'] === 'or') {
  915. // Remember that filters for this field should be tagged.
  916. $escaped = SearchApiSolrConnection::escapeFieldName($fields[$info['field']]);
  917. $taggedFields[$escaped] = "{!tag=$escaped}";
  918. // Add the facet field.
  919. $facet_params['facet.field'][] = "{!ex=$escaped}$field";
  920. }
  921. else {
  922. // Add the facet field.
  923. $facet_params['facet.field'][] = $field;
  924. }
  925. // Set limit, unless it's the default.
  926. if ($info['limit'] != 10) {
  927. $facet_params["f.$field.facet.limit"] = $info['limit'] ? $info['limit'] : -1;
  928. }
  929. // Set mincount, unless it's the default.
  930. if ($info['min_count'] != 1) {
  931. $facet_params["f.$field.facet.mincount"] = $info['min_count'];
  932. }
  933. // Set missing, if specified.
  934. if ($info['missing']) {
  935. $facet_params["f.$field.facet.missing"] = 'true';
  936. }
  937. }
  938. // Tag filters of fields with "OR" facets.
  939. foreach ($taggedFields as $field => $tag) {
  940. $regex = '#(?<![^( ])' . preg_quote($field, '#') . ':#';
  941. foreach ($fq as $i => $filter) {
  942. // Solr can't handle two tags on the same filter, so we don't add two.
  943. // Another option here would even be to remove the other tag, too,
  944. // since we can be pretty sure that this filter does not originate from
  945. // a facet – however, wrong results would still be possible, and this is
  946. // definitely an edge case, so don't bother.
  947. if (preg_match($regex, $filter) && substr($filter, 0, 6) != '{!tag=') {
  948. $fq[$i] = $tag . $filter;
  949. }
  950. }
  951. }
  952. return $facet_params;
  953. }
  954. /**
  955. * Helper method for creating the highlighting parameters.
  956. *
  957. * (The $query parameter currently isn't used and only here for the potential
  958. * sake of subclasses.)
  959. */
  960. protected function getHighlightParams(SearchApiQueryInterface $query) {
  961. $highlight_params = array();
  962. if (!empty($this->options['excerpt']) || !empty($this->options['highlight_data'])) {
  963. $highlight_params['hl'] = 'true';
  964. $highlight_params['hl.fl'] = 'spell';
  965. $highlight_params['hl.simple.pre'] = '[HIGHLIGHT]';
  966. $highlight_params['hl.simple.post'] = '[/HIGHLIGHT]';
  967. $highlight_params['hl.snippets'] = 3;
  968. $highlight_params['hl.fragsize'] = 70;
  969. $highlight_params['hl.mergeContiguous'] = 'true';
  970. }
  971. if (!empty($this->options['highlight_data'])) {
  972. $highlight_params['hl.fl'] = 't_*';
  973. $highlight_params['hl.snippets'] = 1;
  974. $highlight_params['hl.fragsize'] = 0;
  975. if (!empty($this->options['excerpt'])) {
  976. // If we also generate a "normal" excerpt, set the settings for the
  977. // "spell" field (which we use to generate the excerpt) back to the
  978. // above values.
  979. $highlight_params['f.spell.hl.snippets'] = 3;
  980. $highlight_params['f.spell.hl.fragsize'] = 70;
  981. // It regrettably doesn't seem to be possible to set hl.fl to several
  982. // values, if one contains wild cards (i.e., "t_*,spell" wouldn't work).
  983. $highlight_params['hl.fl'] = '*';
  984. }
  985. }
  986. return $highlight_params;
  987. }
  988. /**
  989. * Helper method for setting the request handler, and making necessary
  990. * adjustments to the request parameters.
  991. *
  992. * @param $handler
  993. * Name of the handler to set.
  994. * @param array $call_args
  995. * An associative array containing all four arguments to the
  996. * Apache_Solr_Service::search() call ("query", "offset", "limit" and
  997. * "params") as references.
  998. *
  999. * @return boolean
  1000. * TRUE iff this method invocation handled the given handler. This allows
  1001. * subclasses to recognize whether the request handler was already set by
  1002. * this method.
  1003. */
  1004. protected function setRequestHandler($handler, array &$call_args) {
  1005. if ($handler == 'pinkPony') {
  1006. $call_args['params']['qt'] = $handler;
  1007. return TRUE;
  1008. }
  1009. return FALSE;
  1010. }
  1011. /**
  1012. * Empty method to allow subclasses to apply custom changes before the query
  1013. * is sent to Solr. Works exactly like hook_search_api_solr_query_alter().
  1014. *
  1015. * @param array $call_args
  1016. * An associative array containing all four arguments to the
  1017. * Apache_Solr_Service::search() call ("query", "offset", "limit" and
  1018. * "params") as references.
  1019. * @param SearchApiQueryInterface $query
  1020. * The SearchApiQueryInterface object representing the executed search query.
  1021. */
  1022. protected function preQuery(array &$call_args, SearchApiQueryInterface $query) {
  1023. }
  1024. /**
  1025. * Empty method to allow subclasses to apply custom changes before search results are returned.
  1026. *
  1027. * Works exactly like hook_search_api_solr_search_results_alter().
  1028. *
  1029. * @param array $results
  1030. * The results array that will be returned for the search.
  1031. * @param SearchApiQueryInterface $query
  1032. * The SearchApiQueryInterface object representing the executed search query.
  1033. * @param Apache_Solr_Response $response
  1034. * The response object returned by Solr.
  1035. */
  1036. protected function postQuery(array &$results, SearchApiQueryInterface $query, Apache_Solr_Response $response) {
  1037. }
  1038. //
  1039. // Autocompletion feature
  1040. //
  1041. /**
  1042. * Get autocompletion suggestions for some user input.
  1043. *
  1044. * @param SearchApiQueryInterface $query
  1045. * A query representing the completed user input so far.
  1046. * @param SearchApiAutocompleteSearch $search
  1047. * An object containing details about the search the user is on, and
  1048. * settings for the autocompletion.
  1049. * @param string $incomplete_key
  1050. * The start of another fulltext keyword for the search, which should be
  1051. * completed.
  1052. * @param string $user_input
  1053. * The complete user input for the fulltext search keywords so far.
  1054. *
  1055. * @return array
  1056. * An array of suggestion. Each suggestion is either a simple string
  1057. * containing the whole suggested keywords, or an array containing the
  1058. * following keys:
  1059. * - prefix: For special suggestions, some kind of prefix describing them.
  1060. * - suggestion_prefix: A suggested prefix for the entered input.
  1061. * - user_input: The input entered by the user. Defaults to $user_input.
  1062. * - suggestion_suffix: A suggested suffix for the entered input.
  1063. * - results: If available, the estimated number of results for these keys.
  1064. */
  1065. // Largely copied from the apachesolr_autocomplete module.
  1066. public function getAutocompleteSuggestions(SearchApiQueryInterface $query, SearchApiAutocompleteSearch $search, $incomplete_key, $user_input) {
  1067. $suggestions = array();
  1068. // Reset request handler
  1069. $this->request_handler = NULL;
  1070. // Turn inputs to lower case, otherwise we get case sensivity problems.
  1071. $incomp = drupal_strtolower($incomplete_key);
  1072. $index = $query->getIndex();
  1073. $fields = $this->getFieldNames($index);
  1074. $complete = $query->getOriginalKeys();
  1075. // Extract keys
  1076. $keys = $query->getKeys();
  1077. if (is_array($keys)) {
  1078. $keys_array = array();
  1079. while ($keys) {
  1080. reset($keys);
  1081. if (!element_child(key($keys))) {
  1082. array_shift($keys);
  1083. continue;
  1084. }
  1085. $key = array_shift($keys);
  1086. if (is_array($key)) {
  1087. $keys = array_merge($keys, $key);
  1088. }
  1089. else {
  1090. $keys_array[$key] = $key;
  1091. }
  1092. }
  1093. $keys = $this->flattenKeys($query->getKeys());
  1094. }
  1095. else {
  1096. $keys_array = drupal_map_assoc(preg_split('/[-\s():{}\[\]\\\\"]+/', $keys, -1, PREG_SPLIT_NO_EMPTY));
  1097. }
  1098. if (!$keys) {
  1099. $keys = NULL;
  1100. }
  1101. // Set searched fields
  1102. $options = $query->getOptions();
  1103. $search_fields = $query->getFields();
  1104. $qf = array();
  1105. foreach ($search_fields as $f) {
  1106. $qf[] = $fields[$f];
  1107. }
  1108. // Extract filters
  1109. $fq = $this->createFilterQueries($query->getFilter(), $fields, $index->options['fields']);
  1110. $fq[] = 'index_id:' . $index->machine_name;
  1111. // Autocomplete magic
  1112. $facet_fields = array();
  1113. foreach ($search_fields as $f) {
  1114. $facet_fields[] = $fields[$f];
  1115. }
  1116. $limit = $query->getOption('limit', 10);
  1117. $params = array(
  1118. 'qf' => $qf,
  1119. 'fq' => $fq,
  1120. 'facet' => 'true',
  1121. 'facet.field' => $facet_fields,
  1122. 'facet.prefix' => $incomp,
  1123. 'facet.limit' => $limit * 5,
  1124. 'facet.mincount' => 1,
  1125. 'spellcheck' => (!isset($this->options['autocorrect_spell']) || $this->options['autocorrect_spell']) ? 'true' : 'false',
  1126. 'spellcheck.count' => 1,
  1127. );
  1128. $call_args = array(
  1129. 'query' => &$keys,
  1130. 'offset' => 0,
  1131. 'limit' => 0,
  1132. 'params' => &$params,
  1133. );
  1134. if ($this->request_handler) {
  1135. $this->setRequestHandler($this->request_handler, $call_args);
  1136. }
  1137. $second_pass = !isset($this->options['autocorrect_suggest_words']) || $this->options['autocorrect_suggest_words'];
  1138. for ($i = 0; $i < ($second_pass ? 2 : 1); ++$i) {
  1139. try {
  1140. // Send search request
  1141. $this->connect();
  1142. drupal_alter('search_api_solr_query', $call_args, $query);
  1143. $this->preQuery($call_args, $query);
  1144. $response = $this->solr->search($keys, 0, 0, $params);
  1145. if ($response->getHttpStatus() != 200) {
  1146. watchdog('search_api_solr', 'The Solr server responded with status code @status: @msg.', array('@status' => $response->getHttpStatus(), '@msg' => $response->getHttpStatusMessage()), WATCHDOG_WARNING, 'admin/config/search/search_api/server/' . $this->server->machine_name);
  1147. return array();
  1148. }
  1149. if (!empty($response->spellcheck->suggestions)) {
  1150. $replace = array();
  1151. foreach ($response->spellcheck->suggestions as $word => $data) {
  1152. $replace[$word] = $data->suggestion[0];
  1153. }
  1154. $corrected = str_ireplace(array_keys($replace), array_values($replace), $user_input);
  1155. if ($corrected != $user_input) {
  1156. array_unshift($suggestions, array(
  1157. 'prefix' => t('Did you mean') . ':',
  1158. 'user_input' => $corrected,
  1159. ));
  1160. }
  1161. }
  1162. $matches = array();
  1163. if (isset($response->facet_counts->facet_fields)) {
  1164. foreach ($response->facet_counts->facet_fields as $terms) {
  1165. foreach ($terms as $term => $count) {
  1166. if (isset($matches[$term])) {
  1167. // If we just add the result counts, we can easily get over the
  1168. // total number of results if terms appear in multiple fields.
  1169. // Therefore, we just take the highest value from any field.
  1170. $matches[$term] = max($matches[$term], $count);
  1171. }
  1172. else {
  1173. $matches[$term] = $count;
  1174. }
  1175. }
  1176. }
  1177. if ($matches) {
  1178. // Eliminate suggestions that are too short or already in the query.
  1179. foreach ($matches as $term => $count) {
  1180. if (strlen($term) < 3 || isset($keys_array[$term])) {
  1181. unset($matches[$term]);
  1182. }
  1183. }
  1184. // Don't suggest terms that are too frequent (by default in more
  1185. // than 90% of results).
  1186. $result_count = $response->response->numFound;
  1187. $max_occurrences = $result_count * variable_get('search_api_solr_autocomplete_max_occurrences', 0.9);
  1188. if (($max_occurrences >= 1 || $i > 0) && $max_occurrences < $result_count) {
  1189. foreach ($matches as $match => $count) {
  1190. if ($count > $max_occurrences) {
  1191. unset($matches[$match]);
  1192. }
  1193. }
  1194. }
  1195. // The $count in this array is actually a score. We want the
  1196. // highest ones first.
  1197. arsort($matches);
  1198. // Shorten the array to the right ones.
  1199. $additional_matches = array_slice($matches, $limit - count($suggestions), NULL, TRUE);
  1200. $matches = array_slice($matches, 0, $limit, TRUE);
  1201. // Build suggestions using returned facets
  1202. $incomp_length = strlen($incomp);
  1203. foreach ($matches as $term => $count) {
  1204. if (drupal_strtolower(substr($term, 0, $incomp_length)) == $incomp) {
  1205. $suggestions[] = array(
  1206. 'suggestion_suffix' => substr($term, $incomp_length),
  1207. 'results' => $count,
  1208. );
  1209. }
  1210. else {
  1211. $suggestions[] = array(
  1212. 'suggestion_suffix' => ' ' . $term,
  1213. 'results' => $count,
  1214. );
  1215. }
  1216. }
  1217. }
  1218. }
  1219. }
  1220. catch (Exception $e) {
  1221. watchdog_exception('search_api_solr', $e, "%type during autocomplete Solr query: !message in %function (line %line of %file).", array(), WATCHDOG_WARNING);
  1222. }
  1223. if (count($suggestions) >= $limit) {
  1224. break;
  1225. }
  1226. // Change parameters for second query.
  1227. unset($params['facet.prefix']);
  1228. $keys = trim ($keys . ' ' . $incomplete_key);
  1229. }
  1230. return $suggestions;
  1231. }
  1232. //
  1233. // SearchApiMultiServiceInterface methods
  1234. //
  1235. /**
  1236. * Create a query object for searching on this server.
  1237. *
  1238. * @param $options
  1239. * Associative array of options configuring this query. See
  1240. * SearchApiMultiQueryInterface::__construct().
  1241. *
  1242. * @throws SearchApiException
  1243. * If the server is currently disabled.
  1244. *
  1245. * @return SearchApiMultiQueryInterface
  1246. * An object for searching this server.
  1247. */
  1248. public function queryMultiple(array $options = array()) {
  1249. return new SearchApiMultiQuery($this->server, $options);
  1250. }
  1251. /**
  1252. * Executes a search on the server represented by this object.
  1253. *
  1254. * @param SearchApiMultiQueryInterface $query
  1255. * The search query to execute.
  1256. *
  1257. * @throws SearchApiException
  1258. * If an error prevented the search from completing.
  1259. *
  1260. * @return array
  1261. * An associative array containing the search results, as required by
  1262. * SearchApiMultiQueryInterface::execute().
  1263. */
  1264. public function searchMultiple(SearchApiMultiQueryInterface $query) {
  1265. $time_method_called = microtime(TRUE);
  1266. // Get field information
  1267. $solr_fields = array(
  1268. 'search_api_id' => 'ss_search_api_id',
  1269. 'search_api_relevance' => 'score',
  1270. 'search_api_multi_index' => 'index_id',
  1271. );
  1272. $fields = array(
  1273. 'search_api_multi_index' => array(
  1274. 'type' => 'string',
  1275. ),
  1276. );
  1277. foreach ($query->getIndexes() as $index_id => $index) {
  1278. if (empty($index->options['fields'])) {
  1279. continue;
  1280. }
  1281. $prefix = $index_id . ':';
  1282. foreach ($this->getFieldNames($index) as $field => $key) {
  1283. if (!isset($solr_fields[$field])) {
  1284. $solr_fields[$prefix . $field] = $key;
  1285. }
  1286. }
  1287. foreach ($index->options['fields'] as $field => $info) {
  1288. $fields[$prefix . $field] = $info;
  1289. }
  1290. }
  1291. // Extract keys
  1292. $keys = $query->getKeys();
  1293. if (is_array($keys)) {
  1294. $keys = $this->flattenKeys($keys);
  1295. }
  1296. // Set searched fields
  1297. $search_fields = $query->getFields();
  1298. $qf = array();
  1299. foreach ($search_fields as $f) {
  1300. $qf[] = $solr_fields[$f];
  1301. }
  1302. // Extract filters
  1303. $filter = $query->getFilter();
  1304. $fq = $this->createFilterQueries($filter, $solr_fields, $fields);
  1305. // Restrict search to searched indexes.
  1306. $index_filter = array();
  1307. foreach ($query->getIndexes() as $index_id => $index) {
  1308. $index_filter[] = 'index_id:' . SearchApiSolrConnection::phrase($index_id);
  1309. }
  1310. $fq[] = implode(' OR ', $index_filter);
  1311. // Extract sort
  1312. $sort = array();
  1313. foreach ($query->getSort() as $f => $order) {
  1314. $f = $solr_fields[$f];
  1315. if (substr($f, 0, 3) == 'ss_') {
  1316. $f = 'sort_' . substr($f, 3);
  1317. }
  1318. $order = strtolower($order);
  1319. $sort[] = "$f $order";
  1320. }
  1321. // Get facet fields
  1322. $facets = $query->getOption('search_api_facets') ? $query->getOption('search_api_facets') : array();
  1323. $facet_params = $this->getFacetParams($facets, $solr_fields);
  1324. // Set defaults
  1325. if (!$keys) {
  1326. $keys = NULL;
  1327. }
  1328. $options = $query->getOptions();
  1329. $offset = isset($options['offset']) ? $options['offset'] : 0;
  1330. $limit = isset($options['limit']) ? $options['limit'] : 1000000;
  1331. // Collect parameters
  1332. $params = array(
  1333. 'qf' => $qf,
  1334. 'fl' => 'item_id,index_id,score',
  1335. 'fq' => $fq,
  1336. );
  1337. if ($sort) {
  1338. $params['sort'] = implode(', ', $sort);
  1339. }
  1340. if (!empty($facet_params['facet.field'])) {
  1341. $params += $facet_params;
  1342. }
  1343. try {
  1344. // Send search request
  1345. $time_processing_done = microtime(TRUE);
  1346. $this->connect();
  1347. $call_args = array(
  1348. 'query' => &$keys,
  1349. 'offset' => &$offset,
  1350. 'limit' => &$limit,
  1351. 'params' => &$params,
  1352. );
  1353. drupal_alter('search_api_solr_multi_query', $call_args, $query);
  1354. // Retrieve http method from server options.
  1355. $http_method = !empty($this->options['http_method']) ? $this->options['http_method'] : Apache_Solr_Service::METHOD_POST;
  1356. $response = $this->solr->search($keys, $offset, $limit, $params, $http_method);
  1357. $time_query_done = microtime(TRUE);
  1358. if ($response->getHttpStatus() != 200) {
  1359. throw new SearchApiException(t('The Solr server responded with status code @status: @msg.',
  1360. array('@status' => $response->getHttpStatus(), '@msg' => $response->getHttpStatusMessage())));
  1361. }
  1362. // Extract results
  1363. $results = array();
  1364. $results['result count'] = $response->response->numFound;
  1365. $results['results'] = array();
  1366. $tmp = array();
  1367. foreach ($response->response->docs as $id => $doc) {
  1368. $result = array(
  1369. 'id' => $doc->item_id,
  1370. 'index_id' => $doc->index_id,
  1371. 'score' => $doc->score,
  1372. );
  1373. $excerpt = $this->getExcerpt($response, $id, $tmp, array());
  1374. if ($excerpt) {
  1375. $result['excerpt'] = $excerpt;
  1376. }
  1377. $results['results'][$id] = $result;
  1378. }
  1379. // Extract facets
  1380. if (isset($response->facet_counts->facet_fields)) {
  1381. $results['search_api_facets'] = array();
  1382. $facet_fields = $response->facet_counts->facet_fields;
  1383. foreach ($facets as $delta => $info) {
  1384. $field = $this->getFacetField($solr_fields[$info['field']]);
  1385. if (!empty($facet_fields->$field)) {
  1386. $min_count = $info['min_count'];
  1387. $terms = $facet_fields->$field;
  1388. if ($info['missing']) {
  1389. // We have to correctly incorporate the "_empty_" term.
  1390. // This will ensure that the term with the least results is dropped, if the limit would be exceeded.
  1391. $terms = (array) $terms;
  1392. arsort($terms);
  1393. if (count($terms) > $info['limit']) {
  1394. array_pop($terms);
  1395. }
  1396. }
  1397. foreach ($terms as $term => $count) {
  1398. if ($count >= $min_count) {
  1399. $term = $term == '_empty_' ? '!' : '"' . $term . '"';
  1400. $results['search_api_facets'][$delta][] = array(
  1401. 'filter' => $term,
  1402. 'count' => $count,
  1403. );
  1404. }
  1405. }
  1406. if (empty($results['search_api_facets'][$delta]) || count($results['search_api_facets'][$delta]) <= 1) {
  1407. unset($results['search_api_facets'][$delta]);
  1408. }
  1409. }
  1410. }
  1411. }
  1412. // Compute performance
  1413. $time_end = microtime(TRUE);
  1414. $results['performance'] = array(
  1415. 'complete' => $time_end - $time_method_called,
  1416. 'preprocessing' => $time_processing_done - $time_method_called,
  1417. 'execution' => $time_query_done - $time_processing_done,
  1418. 'postprocessing' => $time_end - $time_query_done,
  1419. );
  1420. return $results;
  1421. }
  1422. catch (Exception $e) {
  1423. throw new SearchApiException($e->getMessage());
  1424. }
  1425. }
  1426. //
  1427. // Additional methods that might be used when knowing the service class.
  1428. //
  1429. /**
  1430. * Ping the Solr server to tell whether it can be accessed.
  1431. *
  1432. * Uses the admin/ping request handler.
  1433. */
  1434. public function ping() {
  1435. $this->connect();
  1436. return $this->solr->ping();
  1437. }
  1438. /**
  1439. * Sends a commit command to the Solr server.
  1440. */
  1441. public function commit() {
  1442. try {
  1443. $this->connect();
  1444. return $this->solr->commit(FALSE, FALSE, FALSE);
  1445. }
  1446. catch (Exception $e) {
  1447. watchdog('search_api_solr', 'A commit operation for server @name failed: @msg.',
  1448. array('@name' => $this->server->machine_name, '@msg' => $e->getMessage()), WATCHDOG_WARNING);
  1449. }
  1450. }
  1451. /**
  1452. * Schedules a commit operation for this server.
  1453. *
  1454. * The commit will be sent at the end of the current page request. Multiple
  1455. * calls to this method will still only result in one commit operation.
  1456. */
  1457. public function scheduleCommit() {
  1458. if (!$this->commitScheduled) {
  1459. $this->commitScheduled = TRUE;
  1460. drupal_register_shutdown_function(array($this, 'commit'));
  1461. }
  1462. }
  1463. /**
  1464. * @return SearchApiSolrConnection
  1465. * The solr connection object used by this server.
  1466. */
  1467. public function getSolrConnection() {
  1468. $this->connect();
  1469. return $this->solr;
  1470. }
  1471. /**
  1472. * Get metadata about fields in the Solr/Lucene index.
  1473. *
  1474. * @param boolean $reset
  1475. * Reload the cached data?
  1476. */
  1477. public function getFields($reset = FALSE) {
  1478. $cid = 'search_api_solr:fields:' . $this->server->machine_name;
  1479. // If the data hasn't been retrieved before and we aren't refreshing it, try
  1480. // to get data from the cache.
  1481. if (!isset($this->fields) && !$reset) {
  1482. $cache = cache_get($cid);
  1483. if (isset($cache->data) && !$reset) {
  1484. $this->fields = $cache->data;
  1485. }
  1486. }
  1487. // If there was no data in the cache, or if we're refreshing the data,
  1488. // connect to the Solr server, retrieve schema information, and cache it.
  1489. if (!isset($this->fields) || $reset) {
  1490. $this->connect();
  1491. $this->fields = array();
  1492. foreach ($this->solr->getFields() as $name => $info) {
  1493. $this->fields[$name] = new SearchApiSolrField($info);
  1494. }
  1495. cache_set($cid, $this->fields);
  1496. }
  1497. return $this->fields;
  1498. }
  1499. }