Passed
Pull Request — development (#3442)
by Elk
12:13 queued 06:23
created

Sphinxql::searchSort()   A

Complexity

Conditions 5
Paths 16

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
cc 5
eloc 3
nc 16
nop 2
dl 0
loc 6
rs 9.6111
c 0
b 0
f 0
ccs 0
cts 5
cp 0
crap 30
1
<?php
2
3
/**
4
 * Used when an Sphinx search daemon is running and Access is via Sphinx's own
5
 * implementation of MySQL network protocol (SphinxQL)
6
 *
7
 * @package   ElkArte Forum
8
 * @copyright ElkArte Forum contributors
9
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
10
 *
11
 * This file contains code covered by:
12
 * copyright: 2011 Simple Machines (http://www.simplemachines.org)
13
 *
14
 * @version 2.0 dev
15
 *
16
 */
17
18
namespace ElkArte\Search\API;
19
20
use ElkArte\Cache\Cache;
21
use ElkArte\Errors\Errors;
22
use ElkArte\User;
23
24
/**
25
 * SearchAPI-Sphinxql.class.php, SphinxQL API,
26
 *
27
 * What it does:
28
 *
29
 * - Used when an Sphinx search daemon is running
30
 * - Access is via Sphinx's own implementation of MySQL network protocol (SphinxQL)
31
 * - Requires Sphinx 2.3 or higher
32
 *
33
 * @package Search
34
 */
35
class Sphinxql extends AbstractAPI
36
{
37
	/**
38
	 * This is the last version of ElkArte that this was tested on, to protect against API changes.
39
	 *
40
	 * @var string
41
	 */
42
	public $version_compatible = 'ElkArte 2.0 dev';
43
44
	/**
45
	 * This won't work with versions of ElkArte less than this.
46
	 *
47
	 * @var string
48
	 */
49
	public $min_elk_version = 'ElkArte 1.0 Beta 1';
50
51
	/**
52
	 * Is it supported?
53
	 *
54
	 * @var bool
55
	 */
56
	public $is_supported = true;
57
58
	/**
59
	 * What words are banned?
60
	 *
61
	 * @var array
62
	 */
63
	protected $bannedWords = [];
64
65
	/**
66
	 * What is the minimum word length?
67
	 *
68
	 * @var int
69
	 */
70
	protected $min_word_length = 4;
71
72
	/**
73
	 * What databases are supported?
74
	 *
75
	 * @var array
76
	 */
77
	protected $supported_databases = ['MySQL'];
78
79
	/**
80
	 * Nothing to do ...
81
	 */
82
	public function __construct($config, $searchParams)
83
	{
84
		parent::__construct($config, $searchParams);
85
86
		// Is this database supported?
87
		if (!in_array($this->_db->title(), $this->supported_databases))
88
		{
89
			$this->is_supported = false;
90
91
			return;
92
		}
93
	}
94
95
	/**
96
	 * If the settings don't exist we can't continue.
97
	 */
98
	public function isValid()
99
	{
100
		global $modSettings;
101
102
		return !empty($modSettings['sphinx_searchd_server']) && !empty($modSettings['sphinxql_searchd_port']);
103
	}
104
105
	/**
106
	 * {@inheritdoc }
107
	 */
108
	public function indexedWordQuery($words, $search_data)
109
	{
110
		// Sphinx uses its internal engine
111
	}
112
113
	/**
114
	 * {@inheritdoc }
115
	 */
116
	public function prepareIndexes($word, &$wordsSearch, &$wordsExclude, $isExcluded, $excludedSubjectWords)
117
	{
118
		$subwords = text2words($word, null, false);
119
120
		$fulltextWord = count($subwords) === 1 ? $word : '"' . $word . '"';
121
		$wordsSearch['indexed_words'][] = $fulltextWord;
122
		if ($isExcluded !== false)
123
		{
124
			$wordsExclude[] = $fulltextWord;
125
		}
126
	}
127
128
	/**
129
	 * {@inheritdoc }
130
	 */
131
	public function searchQuery($search_words, $excluded_words, &$participants)
0 ignored issues
show
Unused Code introduced by
The parameter $search_words is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

131
	public function searchQuery(/** @scrutinizer ignore-unused */ $search_words, $excluded_words, &$participants)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $excluded_words is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

131
	public function searchQuery($search_words, /** @scrutinizer ignore-unused */ $excluded_words, &$participants)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
132
	{
133
		global $context, $modSettings;
134
135
		// Only request the results if they haven't been cached yet.
136
		$cached_results = [];
137
		$cache_key = 'searchql_results_' . md5(User::$info->query_see_board . '_' . $context['params']);
138
		if (!Cache::instance()->getVar($cached_results, $cache_key))
139
		{
140
			// Connect to the sphinx searchd and set a few options.
141
			$mySphinx = $this->sphinxConnect();
142
143
			// Compile different options for our query
144
			$index = (!empty($modSettings['sphinx_index_prefix']) ? $modSettings['sphinx_index_prefix'] : 'elkarte') . '_index';
145
			$query = 'SELECT *' . (empty($this->_searchParams->topic) ? ', COUNT(*) num' : '') . ', WEIGHT() relevance FROM ' . $index;
0 ignored issues
show
Bug Best Practice introduced by
The property topic does not exist on ElkArte\Search\SearchParams. Since you implemented __get, consider adding a @property annotation.
Loading history...
146
147
			// Construct the (binary mode & |) query.
148
			$where_match = $this->_searchArray->searchArrayExtended($this->_searchParams->search);
0 ignored issues
show
Bug Best Practice introduced by
The property search does not exist on ElkArte\Search\SearchParams. Since you implemented __get, consider adding a @property annotation.
Loading history...
149
150
			// Nothing to search, return zero results
151
			if (trim($where_match) === '')
152
			{
153
				return 0;
154
			}
155
156
			if ($this->_searchParams->subject_only)
0 ignored issues
show
Bug Best Practice introduced by
The property subject_only does not exist on ElkArte\Search\SearchParams. Since you implemented __get, consider adding a @property annotation.
Loading history...
157
			{
158
				$where_match = '@subject ' . $where_match;
159
			}
160
161
			$query .= ' WHERE MATCH(\'' . $where_match . '\')';
162
163
			// Set the limits based on the search parameters, board, member, dates, etc
164
			$extra_where = $this->buildQueryLimits();
165
			if (!empty($extra_where))
166
			{
167
				$query .= ' AND ' . implode(' AND ', $extra_where);
168
			}
169
170
			// Put together a sort string; besides the main column sort (relevance, id_topic, or num_replies)
171
			$this->_searchParams->sort_dir = strtoupper($this->_searchParams->sort_dir);
0 ignored issues
show
Bug introduced by
It seems like $this->_searchParams->sort_dir can also be of type null; however, parameter $string of strtoupper() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

171
			$this->_searchParams->sort_dir = strtoupper(/** @scrutinizer ignore-type */ $this->_searchParams->sort_dir);
Loading history...
Bug Best Practice introduced by
The property sort_dir does not exist on ElkArte\Search\SearchParams. Since you implemented __set, consider adding a @property annotation.
Loading history...
Bug Best Practice introduced by
The property sort_dir does not exist on ElkArte\Search\SearchParams. Since you implemented __get, consider adding a @property annotation.
Loading history...
172
			$sphinx_sort = $this->_searchParams->sort === 'id_msg' ? 'id_topic' : $this->_searchParams->sort;
0 ignored issues
show
Bug Best Practice introduced by
The property sort does not exist on ElkArte\Search\SearchParams. Since you implemented __get, consider adding a @property annotation.
Loading history...
173
174
			// Add secondary sorting based on relevance(rank) value (if not the main sort method) and age
175
			$sphinx_sort .= ' ' . $this->_searchParams->sort_dir . ($this->_searchParams->sort === 'relevance' ? '' : ', relevance DESC') . ', poster_time DESC';
176
177
			// Grouping by topic id makes it return only one result per topic, so don't set that for in-topic searches
178
			if (empty($this->_searchParams->topic))
179
			{
180
				// In the topic group, use the most weighty result for display purposes
181
				$query .= ' GROUP BY id_topic WITHIN GROUP ORDER BY relevance DESC';
182
			}
183
184
			$query .= ' ORDER BY ' . $sphinx_sort;
185
186
			// Set any options needed, like field weights.
187
			// ranker is a modification of SPH_RANK_SPH04 sum((4*lcs+2*(min_hit_pos==1)+exact_hit)*user_weight)*1000+bm25
188
			// Each term will return a 0-1000 range we include our acprel value for the final total and order.  Position
189
			// is the relative reply # to a post, so the later a reply in a topic the less overall weight it is given
190
			// the calculated value of ranker is returned in WEIGHTS() which we name relevance in the query
191
			$subject_weight = !empty($modSettings['search_weight_subject']) ? $modSettings['search_weight_subject'] : 30;
192
			$query .= '
193
			OPTION 
194
				field_weights=(subject=' . $subject_weight . ', body=' . (100 - $subject_weight) . '),
195
				ranker=expr(\'sum((4*lcs+2*(min_hit_pos==1)+word_count)*user_weight*position) + acprel + bm25 \'),
196
				idf=plain,
197
				boolean_simplify=1,
198
				max_matches=' . min(500, $modSettings['sphinx_max_results']);
199
200
			// Execute the search query.
201
			$request = mysqli_query($mySphinx, $query);
202
203
			// Bad query, lets log the error and act like its not our fault
204
			if ($request === false)
205
			{
206
				// Just log the error.
207
				if (mysqli_error($mySphinx) !== '')
208
				{
209
					Errors::instance()->log_error(mysqli_error($mySphinx));
210
				}
211
212
				Errors::instance()->fatal_lang_error('error_no_search_daemon');
213
			}
214
215
			// Get the relevant information from the search results.
216
			$cached_results = array(
217
				'num_results' => 0,
218
				'matches' => [],
219
			);
220
221
			if (mysqli_num_rows($request) !== 0)
0 ignored issues
show
Bug introduced by
It seems like $request can also be of type true; however, parameter $result of mysqli_num_rows() does only seem to accept mysqli_result, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

221
			if (mysqli_num_rows(/** @scrutinizer ignore-type */ $request) !== 0)
Loading history...
222
			{
223
				while (($match = mysqli_fetch_assoc($request)))
0 ignored issues
show
Bug introduced by
It seems like $request can also be of type true; however, parameter $result of mysqli_fetch_assoc() does only seem to accept mysqli_result, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

223
				while (($match = mysqli_fetch_assoc(/** @scrutinizer ignore-type */ $request)))
Loading history...
224
				{
225
					$num = 0;
226
					if (empty($this->_searchParams->topic))
227
					{
228
						$num = isset($match['num']) ? $match['num'] : (isset($match['@count']) ? $match['@count'] : 0);
229
					}
230
231
					$cached_results['matches'][$match['id']] = array(
232
						'id' => $match['id_topic'],
233
						'num_matches' => $num,
234
						'matches' => [],
235
						'relevance' => round($match['relevance'], 0),
236
					);
237
				}
238
			}
239
			mysqli_free_result($request);
0 ignored issues
show
Bug introduced by
It seems like $request can also be of type true; however, parameter $result of mysqli_free_result() does only seem to accept mysqli_result, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

239
			mysqli_free_result(/** @scrutinizer ignore-type */ $request);
Loading history...
240
			mysqli_close($mySphinx);
241
242
			$cached_results['num_results'] = count($cached_results['matches']);
243
244
			// Store the search results in the cache.
245
			Cache::instance()->put($cache_key, $cached_results, 600);
246
		}
247
248
		$participants = [];
249
		$topics = [];
250
		foreach (array_slice(array_keys($cached_results['matches']), $this->_req->getRequest('start', 'intval', 0), $modSettings['search_results_per_page']) as $msgID)
251
		{
252
			$topics[$msgID] = $cached_results['matches'][$msgID];
253
			$participants[$cached_results['matches'][$msgID]['id']] = false;
254
		}
255
256
		$this->_num_results = $cached_results['num_results'];
257
258
		return $topics;
259
	}
260
261
	/**
262
	 * Connect to the sphinx server, on failure log error and exit
263
	 *
264
	 * @return \mysqli
265
	 * @throws \ElkArte\Exceptions\Exception
266
	 */
267
	private function sphinxConnect()
268
	{
269
		global $modSettings;
270
271
		set_error_handler(function () { /* ignore errors */ });
272
		try
273
		{
274
			$mySphinx = mysqli_connect(($modSettings['sphinx_searchd_server'] === 'localhost' ? '127.0.0.1' : $modSettings['sphinx_searchd_server']), '', '', '', (int) $modSettings['sphinxql_searchd_port']);
0 ignored issues
show
Bug introduced by
The call to mysqli_connect() has too few arguments starting with socket. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

274
			$mySphinx = /** @scrutinizer ignore-call */ mysqli_connect(($modSettings['sphinx_searchd_server'] === 'localhost' ? '127.0.0.1' : $modSettings['sphinx_searchd_server']), '', '', '', (int) $modSettings['sphinxql_searchd_port']);

This check compares calls to functions or methods with their respective definitions. If the call has less arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
275
		}
276
		catch (\Exception $e)
277
		{
278
			$mySphinx = false;
279
		}
280
		finally
281
		{
282
			restore_error_handler();
283
		};
284
285
		// No connection, daemon not running?  log the error and exit
286
		if ($mySphinx === false)
287
		{
288
			Errors::instance()->fatal_lang_error('error_no_search_daemon');
289
		}
290
291
		return $mySphinx;
292
	}
293
294
	/**
295
	 * {@inheritdoc }
296
	 */
297
	public function useWordIndex()
298
	{
299
		return false;
300
	}
301
302
	/**
303
	 * Builds the query modifiers based on age, member, board etc
304
	 *
305
	 * @return array
306
	 */
307
	public function buildQueryLimits()
308
	{
309
		global $modSettings;
310
311
		$extra_where = [];
312
313
		if (!empty($this->_searchParams->_minMsgID) || !empty($this->_searchParams->_maxMsgID))
314
		{
315
			$extra_where[] = 'id >= ' . $this->_searchParams->_minMsgID . ' AND id <= ' . (empty($this->_searchParams->_maxMsgID) ? (int) $modSettings['maxMsgID'] : $this->_searchParams->_maxMsgID);
316
		}
317
318
		if (!empty($this->_searchParams->topic))
0 ignored issues
show
Bug Best Practice introduced by
The property topic does not exist on ElkArte\Search\SearchParams. Since you implemented __get, consider adding a @property annotation.
Loading history...
319
		{
320
			$extra_where[] = 'id_topic = ' . (int) $this->_searchParams->topic;
321
		}
322
323
		if (!empty($this->_searchParams->brd))
0 ignored issues
show
Bug Best Practice introduced by
The property brd does not exist on ElkArte\Search\SearchParams. Since you implemented __get, consider adding a @property annotation.
Loading history...
324
		{
325
			$extra_where[] = 'id_board IN (' . implode(',', $this->_searchParams->brd) . ')';
326
		}
327
328
		if (!empty($this->_searchParams->_memberlist))
329
		{
330
			$extra_where[] = 'id_member IN (' . implode(',', $this->_searchParams->_memberlist) . ')';
331
		}
332
333
		return $extra_where;
334
	}
335
}
336