Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.69% covered (danger)
9.69%
47 / 485
4.76% covered (danger)
4.76%
1 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
fulltext_mysql
9.69% covered (danger)
9.69%
47 / 485
4.76% covered (danger)
4.76%
1 / 21
15630.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 get_name
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_available
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 init
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
90
 get_search_query
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_common_words
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_word_length
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 split_keywords
72.73% covered (warning)
72.73%
40 / 55
0.00% covered (danger)
0.00%
0 / 1
37.68
 keyword_search
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 1
992
 author_search
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 1
930
 supports_phrase_search
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 index
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 index_remove
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tidy
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 create_index
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
72
 delete_index
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
72
 index_created
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 index_stats
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 get_stats
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 split_message
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 get_acp_options
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3*
4* This file is part of the phpBB Forum Software package.
5*
6* @copyright (c) phpBB Limited <https://www.phpbb.com>
7* @license GNU General Public License, version 2 (GPL-2.0)
8*
9* For full copyright and license information, please see
10* the docs/CREDITS.txt file.
11*
12*/
13
14namespace phpbb\search\backend;
15
16use phpbb\config\config;
17use phpbb\db\driver\driver_interface;
18use phpbb\event\dispatcher_interface;
19use phpbb\language\language;
20use phpbb\search\exception\search_exception;
21use phpbb\user;
22
23/**
24* Fulltext search for MySQL
25*/
26class fulltext_mysql extends base implements search_backend_interface
27{
28    /**
29     * Associative array holding index stats
30     * @var array
31     */
32    protected $stats = array();
33
34    /**
35     * Holds the words entered by user, obtained by splitting the entered query on whitespace
36     * @var array
37     */
38    protected $split_words = array();
39
40    /**
41     * phpBB event dispatcher object
42     * @var dispatcher_interface
43     */
44    protected $phpbb_dispatcher;
45
46    /**
47     * @var language
48     */
49    protected $language;
50
51    /**
52     * Associative array stores the min and max word length to be searched
53     * @var array
54     */
55    protected $word_length = array();
56
57    /**
58     * Contains tidied search query.
59     * Operators are prefixed in search query and common words excluded
60     * @var string
61     */
62    protected $search_query = '';
63
64    /**
65     * Contains common words.
66     * Common words are words with length less/more than min/max length
67     * @var array
68     */
69    protected $common_words = array();
70
71    /**
72     * Constructor
73     * Creates a new \phpbb\search\backend\fulltext_mysql, which is used as a search backend
74     *
75     * @param config                $config                Config object
76     * @param driver_interface        $db                    Database object
77     * @param dispatcher_interface    $phpbb_dispatcher    Event dispatcher object
78     * @param language                $language
79     * @param user                    $user                User object
80     * @param string                $search_results_table
81     * @param string                $phpbb_root_path    Relative path to phpBB root
82     * @param string                $phpEx                PHP file extension
83     */
84    public function __construct(config $config, driver_interface $db, dispatcher_interface $phpbb_dispatcher, language $language, user $user, string $search_results_table, string $phpbb_root_path, string $phpEx)
85    {
86        global $cache;
87
88        parent::__construct($cache, $config, $db, $user, $search_results_table);
89        $this->phpbb_dispatcher = $phpbb_dispatcher;
90        $this->language = $language;
91
92        $this->word_length = array('min' => $this->config['fulltext_mysql_min_word_len'], 'max' => $this->config['fulltext_mysql_max_word_len']);
93
94        /**
95         * Load the UTF tools
96         */
97        if (!function_exists('utf8_strlen'))
98        {
99            include($phpbb_root_path . 'includes/utf/utf_tools.' . $phpEx);
100        }
101    }
102
103    /**
104     * {@inheritdoc}
105     */
106    public function get_name(): string
107    {
108        return 'MySQL Fulltext';
109    }
110
111    /**
112     * {@inheritdoc}
113     */
114    public function is_available(): bool
115    {
116        // Check if we are using mysql
117        if ($this->db->get_sql_layer() != 'mysqli')
118        {
119            return false;
120        }
121
122        return true;
123    }
124
125    /**
126     * {@inheritdoc}
127     */
128    public function init()
129    {
130        if (!$this->is_available())
131        {
132            return $this->language->lang('FULLTEXT_MYSQL_INCOMPATIBLE_DATABASE');
133        }
134
135        $result = $this->db->sql_query('SHOW TABLE STATUS LIKE \'' . POSTS_TABLE . '\'');
136        $info = $this->db->sql_fetchrow($result);
137        $this->db->sql_freeresult($result);
138
139        $engine = $info['Engine'] ?? $info['Type'] ?? '';
140
141        $fulltext_supported = $engine === 'Aria' || $engine === 'MyISAM'
142            /**
143             * FULLTEXT is supported on InnoDB since MySQL 5.6.4 according to
144             * http://dev.mysql.com/doc/refman/5.6/en/innodb-storage-engine.html
145             * We also require https://bugs.mysql.com/bug.php?id=67004 to be
146             * fixed for proper overall operation. Hence we require 5.6.8.
147             */
148            || ($engine === 'InnoDB'
149                && phpbb_version_compare($this->db->sql_server_info(true), '5.6.8', '>='));
150
151        if (!$fulltext_supported)
152        {
153            return $this->language->lang('FULLTEXT_MYSQL_NOT_SUPPORTED');
154        }
155
156        $sql = 'SHOW VARIABLES
157            LIKE \'%ft\_%\'';
158        $result = $this->db->sql_query($sql);
159
160        $mysql_info = array();
161        while ($row = $this->db->sql_fetchrow($result))
162        {
163            $mysql_info[$row['Variable_name']] = $row['Value'];
164        }
165        $this->db->sql_freeresult($result);
166
167        if ($engine === 'MyISAM')
168        {
169            $this->config->set('fulltext_mysql_max_word_len', $mysql_info['ft_max_word_len']);
170            $this->config->set('fulltext_mysql_min_word_len', $mysql_info['ft_min_word_len']);
171        }
172        else if ($engine === 'InnoDB')
173        {
174            $this->config->set('fulltext_mysql_max_word_len', $mysql_info['innodb_ft_max_token_size']);
175            $this->config->set('fulltext_mysql_min_word_len', $mysql_info['innodb_ft_min_token_size']);
176        }
177
178        return false;
179    }
180
181    /**
182     * {@inheritdoc}
183     */
184    public function get_search_query(): string
185    {
186        return $this->search_query;
187    }
188
189    /**
190     * {@inheritdoc}
191     */
192    public function get_common_words(): array
193    {
194        return $this->common_words;
195    }
196
197    /**
198     * {@inheritdoc}
199     */
200    public function get_word_length()
201    {
202        return $this->word_length;
203    }
204
205    /**
206     * {@inheritdoc}
207     */
208    public function split_keywords(string &$keywords, string $terms): bool
209    {
210        if ($terms == 'all')
211        {
212            $match        = array('#\sand\s#iu', '#\sor\s#iu', '#\snot\s#iu', '#(^|\s)\+#', '#(^|\s)-#', '#(^|\s)\|#');
213            $replace    = array(' +', ' |', ' -', ' +', ' -', ' |');
214
215            $keywords = preg_replace($match, $replace, $keywords);
216        }
217
218        // Filter out as above
219        $split_keywords = preg_replace("#[\n\r\t]+#", ' ', trim(html_entity_decode($keywords, ENT_COMPAT)));
220
221        // Split words
222        $split_keywords = preg_replace('#([^\p{L}\p{N}\'*"()])#u', '$1$1', str_replace('\'\'', '\' \'', trim($split_keywords)));
223        $matches = array();
224        preg_match_all('#(?:[^\p{L}\p{N}*"()]|^)([+\-|]?(?:[\p{L}\p{N}*"()]+\'?)*[\p{L}\p{N}*"()])(?:[^\p{L}\p{N}*"()]|$)#u', $split_keywords, $matches);
225        $this->split_words = $matches[1];
226
227        // We limit the number of allowed keywords to minimize load on the database
228        if ($this->config['max_num_search_keywords'] && count($this->split_words) > $this->config['max_num_search_keywords'])
229        {
230            trigger_error($this->language->lang('MAX_NUM_SEARCH_KEYWORDS_REFINE', (int) $this->config['max_num_search_keywords'], count($this->split_words)));
231        }
232
233        // to allow phrase search, we need to concatenate quoted words
234        $tmp_split_words = array();
235        $phrase = '';
236        foreach ($this->split_words as $word)
237        {
238            if ($phrase)
239            {
240                $phrase .= ' ' . $word;
241                if (strpos($word, '"') !== false && substr_count($word, '"') % 2 == 1)
242                {
243                    $tmp_split_words[] = $phrase;
244                    $phrase = '';
245                }
246            }
247            else if (strpos($word, '"') !== false && substr_count($word, '"') % 2 == 1)
248            {
249                $phrase = $word;
250            }
251            else
252            {
253                $tmp_split_words[] = $word;
254            }
255        }
256        if ($phrase)
257        {
258            $tmp_split_words[] = $phrase;
259        }
260
261        $this->split_words = $tmp_split_words;
262
263        unset($tmp_split_words);
264        unset($phrase);
265
266        foreach ($this->split_words as $i => $word)
267        {
268            // Check for not allowed search queries for InnoDB.
269            // We assume similar restrictions for MyISAM, which is usually even
270            // slower but not as restrictive as InnoDB.
271            // InnoDB full-text search does not support the use of a leading
272            // plus sign with wildcard ('+*'), a plus and minus sign
273            // combination ('+-'), or leading a plus and minus sign combination.
274            // InnoDB full-text search only supports leading plus or minus signs.
275            // For example, InnoDB supports '+apple' but does not support 'apple+'.
276            // Specifying a trailing plus or minus sign causes InnoDB to report
277            // a syntax error. InnoDB full-text search does not support the use
278            // of multiple operators on a single search word, as in this example:
279            // '++apple'. Use of multiple operators on a single search word
280            // returns a syntax error to standard out.
281            // Also, ensure that the wildcard character is only used at the
282            // end of the line as it's intended by MySQL.
283            if (preg_match('#^(\+[+-]|\+\*|.+[+-]$|.+\*(?!$))#', $word))
284            {
285                unset($this->split_words[$i]);
286                continue;
287            }
288
289            $clean_word = preg_replace('#^[+\-|"]#', '', $word);
290
291            // check word length
292            $clean_len = utf8_strlen(str_replace('*', '', $clean_word));
293            if (($clean_len < $this->config['fulltext_mysql_min_word_len']) || ($clean_len > $this->config['fulltext_mysql_max_word_len']))
294            {
295                $this->common_words[] = $word;
296                unset($this->split_words[$i]);
297            }
298        }
299
300        if ($terms == 'any')
301        {
302            $this->search_query = '';
303            foreach ($this->split_words as $word)
304            {
305                if ((strpos($word, '+') === 0) || (strpos($word, '-') === 0) || (strpos($word, '|') === 0))
306                {
307                    $word = substr($word, 1);
308                }
309                $this->search_query .= $word . ' ';
310            }
311        }
312        else
313        {
314            $this->search_query = '';
315            foreach ($this->split_words as $word)
316            {
317                if ((strpos($word, '+') === 0) || (strpos($word, '-') === 0))
318                {
319                    $this->search_query .= $word . ' ';
320                }
321                else if (strpos($word, '|') === 0)
322                {
323                    $this->search_query .= substr($word, 1) . ' ';
324                }
325                else
326                {
327                    $this->search_query .= '+' . $word . ' ';
328                }
329            }
330        }
331
332        $this->search_query = utf8_htmlspecialchars($this->search_query);
333
334        if ($this->search_query)
335        {
336            $this->split_words = array_values($this->split_words);
337            sort($this->split_words);
338            return true;
339        }
340        return false;
341    }
342
343    /**
344     * {@inheritdoc}
345     */
346    public function keyword_search(string $type, string $fields, string $terms, array $sort_by_sql, string $sort_key, string $sort_dir, string $sort_days, array $ex_fid_ary, string $post_visibility, int $topic_id, array $author_ary, string $author_name, array &$id_ary, int &$start, int $per_page)
347    {
348        // No keywords? No posts
349        if (!$this->search_query)
350        {
351            return false;
352        }
353
354        // generate a search_key from all the options to identify the results
355        $search_key_array = array(
356            implode(', ', $this->split_words),
357            $type,
358            $fields,
359            $terms,
360            $sort_days,
361            $sort_key,
362            $topic_id,
363            implode(',', $ex_fid_ary),
364            $post_visibility,
365            implode(',', $author_ary)
366        );
367
368        /**
369        * Allow changing the search_key for cached results
370        *
371        * @event core.search_mysql_by_keyword_modify_search_key
372        * @var    array    search_key_array    Array with search parameters to generate the search_key
373        * @var    string    type                Searching type ('posts', 'topics')
374        * @var    string    fields                Searching fields ('titleonly', 'msgonly', 'firstpost', 'all')
375        * @var    string    terms                Searching terms ('all', 'any')
376        * @var    int        sort_days            Time, in days, of the oldest possible post to list
377        * @var    string    sort_key            The sort type used from the possible sort types
378        * @var    int        topic_id            Limit the search to this topic_id only
379        * @var    array    ex_fid_ary            Which forums not to search on
380        * @var    string    post_visibility        Post visibility data
381        * @var    array    author_ary            Array of user_id containing the users to filter the results to
382        * @since 3.1.7-RC1
383        */
384        $vars = array(
385            'search_key_array',
386            'type',
387            'fields',
388            'terms',
389            'sort_days',
390            'sort_key',
391            'topic_id',
392            'ex_fid_ary',
393            'post_visibility',
394            'author_ary',
395        );
396        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_by_keyword_modify_search_key', compact($vars)));
397
398        $search_key = md5(implode('#', $search_key_array));
399
400        if ($start < 0)
401        {
402            $start = 0;
403        }
404
405        // try reading the results from cache
406        $result_count = 0;
407        if ($this->obtain_ids($search_key, $result_count, $id_ary, $start, $per_page, $sort_dir) == self::SEARCH_RESULT_IN_CACHE)
408        {
409            return $result_count;
410        }
411
412        $id_ary = array();
413
414        $join_topic = ($type == 'posts') ? false : true;
415
416        // Build sql strings for sorting
417        $sql_sort = $sort_by_sql[$sort_key] . (($sort_dir == 'a') ? ' ASC' : ' DESC');
418        $sql_sort_table = $sql_sort_join = '';
419
420        switch ($sql_sort[0])
421        {
422            case 'u':
423                $sql_sort_table    = USERS_TABLE . ' u, ';
424                $sql_sort_join    = ($type == 'posts') ? ' AND u.user_id = p.poster_id ' : ' AND u.user_id = t.topic_poster ';
425            break;
426
427            case 't':
428                $join_topic = true;
429            break;
430
431            case 'f':
432                $sql_sort_table    = FORUMS_TABLE . ' f, ';
433                $sql_sort_join    = ' AND f.forum_id = p.forum_id ';
434            break;
435        }
436
437        // Build some display specific sql strings
438        switch ($fields)
439        {
440            case 'titleonly':
441                $sql_match = 'p.post_subject';
442                $sql_match_where = ' AND p.post_id = t.topic_first_post_id';
443                $join_topic = true;
444            break;
445
446            case 'msgonly':
447                $sql_match = 'p.post_text';
448                $sql_match_where = '';
449            break;
450
451            case 'firstpost':
452                $sql_match = 'p.post_subject, p.post_text';
453                $sql_match_where = ' AND p.post_id = t.topic_first_post_id';
454                $join_topic = true;
455            break;
456
457            default:
458                $sql_match = 'p.post_subject, p.post_text';
459                $sql_match_where = '';
460            break;
461        }
462
463        $search_query = $this->search_query;
464
465        /**
466        * Allow changing the query used to search for posts using fulltext_mysql
467        *
468        * @event core.search_mysql_keywords_main_query_before
469        * @var    string    search_query        The parsed keywords used for this search
470        * @var    int        result_count        The previous result count for the format of the query.
471        *                                    Set to 0 to force a re-count
472        * @var    bool    join_topic            Weather or not TOPICS_TABLE should be CROSS JOIN'ED
473        * @var    array    author_ary            Array of user_id containing the users to filter the results to
474        * @var    string    author_name            An extra username to search on (!empty(author_ary) must be true, to be relevant)
475        * @var    array    ex_fid_ary            Which forums not to search on
476        * @var    int        topic_id            Limit the search to this topic_id only
477        * @var    string    sql_sort_table        Extra tables to include in the SQL query.
478        *                                    Used in conjunction with sql_sort_join
479        * @var    string    sql_sort_join        SQL conditions to join all the tables used together.
480        *                                    Used in conjunction with sql_sort_table
481        * @var    int        sort_days            Time, in days, of the oldest possible post to list
482        * @var    string    sql_match            Which columns to do the search on.
483        * @var    string    sql_match_where        Extra conditions to use to properly filter the matching process
484        * @var    string    sort_by_sql            The possible predefined sort types
485        * @var    string    sort_key            The sort type used from the possible sort types
486        * @var    string    sort_dir            "a" for ASC or "d" dor DESC for the sort order used
487        * @var    string    sql_sort            The result SQL when processing sort_by_sql + sort_key + sort_dir
488        * @var    int        start                How many posts to skip in the search results (used for pagination)
489        * @since 3.1.5-RC1
490        */
491        $vars = array(
492            'search_query',
493            'result_count',
494            'join_topic',
495            'author_ary',
496            'author_name',
497            'ex_fid_ary',
498            'topic_id',
499            'sql_sort_table',
500            'sql_sort_join',
501            'sort_days',
502            'sql_match',
503            'sql_match_where',
504            'sort_by_sql',
505            'sort_key',
506            'sort_dir',
507            'sql_sort',
508            'start',
509        );
510        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_keywords_main_query_before', compact($vars)));
511
512        $sql_select            = ($type == 'posts') ? 'DISTINCT p.post_id' : 'DISTINCT t.topic_id';
513        $sql_select            .= $sort_by_sql[$sort_key] ? "{$sort_by_sql[$sort_key]}" : '';
514        $sql_from            = ($join_topic) ? TOPICS_TABLE . ' t, ' : '';
515        $field                = ($type == 'posts') ? 'post_id' : 'topic_id';
516        if (count($author_ary) && $author_name)
517        {
518            // first one matches post of registered users, second one guests and deleted users
519            $sql_author = ' AND (' . $this->db->sql_in_set('p.poster_id', array_diff($author_ary, array(ANONYMOUS)), false, true) . ' OR p.post_username ' . $author_name . ')';
520        }
521        else if (count($author_ary))
522        {
523            $sql_author = ' AND ' . $this->db->sql_in_set('p.poster_id', $author_ary);
524        }
525        else
526        {
527            $sql_author = '';
528        }
529
530        $sql_where_options = $sql_sort_join;
531        $sql_where_options .= ($topic_id) ? ' AND p.topic_id = ' . $topic_id : '';
532        $sql_where_options .= ($join_topic) ? ' AND t.topic_id = p.topic_id' : '';
533        $sql_where_options .= (count($ex_fid_ary)) ? ' AND ' . $this->db->sql_in_set('p.forum_id', $ex_fid_ary, true) : '';
534        $sql_where_options .= ' AND ' . $post_visibility;
535        $sql_where_options .= $sql_author;
536        $sql_where_options .= ($sort_days) ? ' AND p.post_time >= ' . (time() - ($sort_days * 86400)) : '';
537        $sql_where_options .= $sql_match_where;
538
539        $sql = "SELECT $sql_select
540            FROM $sql_from$sql_sort_table" . POSTS_TABLE . " p
541            WHERE MATCH ($sql_match) AGAINST ('" . $this->db->sql_escape(html_entity_decode($this->search_query, ENT_COMPAT)) . "' IN BOOLEAN MODE)
542                $sql_where_options
543            ORDER BY $sql_sort";
544        $this->db->sql_return_on_error(true);
545        $result = $this->db->sql_query_limit($sql, $this->config['search_block_size'], $start);
546
547        while ($row = $this->db->sql_fetchrow($result))
548        {
549            $id_ary[] = (int) $row[$field];
550        }
551        $this->db->sql_freeresult($result);
552
553        $id_ary = array_unique($id_ary);
554        // if the total result count is not cached yet, retrieve it from the db
555        if (!$result_count && count($id_ary))
556        {
557            $sql_found_rows = str_replace("SELECT $sql_select", "SELECT COUNT($sql_select) as result_count", $sql);
558            $result = $this->db->sql_query($sql_found_rows);
559            $result_count = (int) $this->db->sql_fetchfield('result_count');
560            $this->db->sql_freeresult($result);
561
562            if (!$result_count)
563            {
564                return false;
565            }
566        }
567
568        if ($start >= $result_count)
569        {
570            $start = floor(($result_count - 1) / $per_page) * $per_page;
571
572            $result = $this->db->sql_query_limit($sql, $this->config['search_block_size'], $start);
573
574            while ($row = $this->db->sql_fetchrow($result))
575            {
576                $id_ary[] = (int) $row[$field];
577            }
578            $this->db->sql_freeresult($result);
579
580            $id_ary = array_unique($id_ary);
581        }
582
583        // store the ids, from start on then delete anything that isn't on the current page because we only need ids for one page
584        $this->save_ids($search_key, implode(' ', $this->split_words), $author_ary, $result_count, $id_ary, $start, $sort_dir);
585        $id_ary = array_slice($id_ary, 0, (int) $per_page);
586
587        return $result_count;
588    }
589
590    /**
591     * {@inheritdoc}
592     */
593    public function author_search(string $type, bool $firstpost_only, array $sort_by_sql, string $sort_key, string $sort_dir, string $sort_days, array $ex_fid_ary, string $post_visibility, int $topic_id, array $author_ary, string $author_name, array &$id_ary, int &$start, int $per_page)
594    {
595        // No author? No posts
596        if (!count($author_ary))
597        {
598            return 0;
599        }
600
601        // generate a search_key from all the options to identify the results
602        $search_key_array = array(
603            '',
604            $type,
605            ($firstpost_only) ? 'firstpost' : '',
606            '',
607            '',
608            $sort_days,
609            $sort_key,
610            $topic_id,
611            implode(',', $ex_fid_ary),
612            $post_visibility,
613            implode(',', $author_ary),
614            $author_name,
615        );
616
617        /**
618        * Allow changing the search_key for cached results
619        *
620        * @event core.search_mysql_by_author_modify_search_key
621        * @var    array    search_key_array    Array with search parameters to generate the search_key
622        * @var    string    type                Searching type ('posts', 'topics')
623        * @var    boolean    firstpost_only        Flag indicating if only topic starting posts are considered
624        * @var    int        sort_days            Time, in days, of the oldest possible post to list
625        * @var    string    sort_key            The sort type used from the possible sort types
626        * @var    int        topic_id            Limit the search to this topic_id only
627        * @var    array    ex_fid_ary            Which forums not to search on
628        * @var    string    post_visibility        Post visibility data
629        * @var    array    author_ary            Array of user_id containing the users to filter the results to
630        * @var    string    author_name            The username to search on
631        * @since 3.1.7-RC1
632        */
633        $vars = array(
634            'search_key_array',
635            'type',
636            'firstpost_only',
637            'sort_days',
638            'sort_key',
639            'topic_id',
640            'ex_fid_ary',
641            'post_visibility',
642            'author_ary',
643            'author_name',
644        );
645        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_by_author_modify_search_key', compact($vars)));
646
647        $search_key = md5(implode('#', $search_key_array));
648
649        if ($start < 0)
650        {
651            $start = 0;
652        }
653
654        // try reading the results from cache
655        $result_count = 0;
656        if ($this->obtain_ids($search_key, $result_count, $id_ary, $start, $per_page, $sort_dir) == self::SEARCH_RESULT_IN_CACHE)
657        {
658            return $result_count;
659        }
660
661        $id_ary = array();
662
663        // Create some display specific sql strings
664        if ($author_name)
665        {
666            // first one matches post of registered users, second one guests and deleted users
667            $sql_author = '(' . $this->db->sql_in_set('p.poster_id', array_diff($author_ary, array(ANONYMOUS)), false, true) . ' OR p.post_username ' . $author_name . ')';
668        }
669        else
670        {
671            $sql_author = $this->db->sql_in_set('p.poster_id', $author_ary);
672        }
673        $sql_fora        = (count($ex_fid_ary)) ? ' AND ' . $this->db->sql_in_set('p.forum_id', $ex_fid_ary, true) : '';
674        $sql_topic_id    = ($topic_id) ? ' AND p.topic_id = ' . (int) $topic_id : '';
675        $sql_time        = ($sort_days) ? ' AND p.post_time >= ' . (time() - ($sort_days * 86400)) : '';
676        $sql_firstpost = ($firstpost_only) ? ' AND p.post_id = t.topic_first_post_id' : '';
677
678        // Build sql strings for sorting
679        $sql_sort = $sort_by_sql[$sort_key] . (($sort_dir == 'a') ? ' ASC' : ' DESC');
680        $sql_sort_table = $sql_sort_join = '';
681        switch ($sql_sort[0])
682        {
683            case 'u':
684                $sql_sort_table    = USERS_TABLE . ' u, ';
685                $sql_sort_join    = ($type == 'posts') ? ' AND u.user_id = p.poster_id ' : ' AND u.user_id = t.topic_poster ';
686            break;
687
688            case 't':
689                $sql_sort_table    = ($type == 'posts' && !$firstpost_only) ? TOPICS_TABLE . ' t, ' : '';
690                $sql_sort_join    = ($type == 'posts' && !$firstpost_only) ? ' AND t.topic_id = p.topic_id ' : '';
691            break;
692
693            case 'f':
694                $sql_sort_table    = FORUMS_TABLE . ' f, ';
695                $sql_sort_join    = ' AND f.forum_id = p.forum_id ';
696            break;
697        }
698
699        $m_approve_fid_sql = ' AND ' . $post_visibility;
700
701        /**
702        * Allow changing the query used to search for posts by author in fulltext_mysql
703        *
704        * @event core.search_mysql_author_query_before
705        * @var    int        result_count        The previous result count for the format of the query.
706        *                                    Set to 0 to force a re-count
707        * @var    string    sql_sort_table        CROSS JOIN'ed table to allow doing the sort chosen
708        * @var    string    sql_sort_join        Condition to define how to join the CROSS JOIN'ed table specifyed in sql_sort_table
709        * @var    string    type                Either "posts" or "topics" specifying the type of search being made
710        * @var    array    author_ary            Array of user_id containing the users to filter the results to
711        * @var    string    author_name            An extra username to search on
712        * @var    string    sql_author            SQL WHERE condition for the post author ids
713        * @var    int        topic_id            Limit the search to this topic_id only
714        * @var    string    sql_topic_id        SQL of topic_id
715        * @var    string    sort_by_sql            The possible predefined sort types
716        * @var    string    sort_key            The sort type used from the possible sort types
717        * @var    string    sort_dir            "a" for ASC or "d" dor DESC for the sort order used
718        * @var    string    sql_sort            The result SQL when processing sort_by_sql + sort_key + sort_dir
719        * @var    string    sort_days            Time, in days, that the oldest post showing can have
720        * @var    string    sql_time            The SQL to search on the time specifyed by sort_days
721        * @var    bool    firstpost_only        Wether or not to search only on the first post of the topics
722        * @var    string    sql_firstpost        The SQL with the conditions to join the tables when using firstpost_only
723        * @var    array    ex_fid_ary            Forum ids that must not be searched on
724        * @var    array    sql_fora            SQL query for ex_fid_ary
725        * @var    string    m_approve_fid_sql    WHERE clause condition on post_visibility restrictions
726        * @var    int        start                How many posts to skip in the search results (used for pagination)
727        * @since 3.1.5-RC1
728        */
729        $vars = array(
730            'result_count',
731            'sql_sort_table',
732            'sql_sort_join',
733            'type',
734            'author_ary',
735            'author_name',
736            'sql_author',
737            'topic_id',
738            'sql_topic_id',
739            'sort_by_sql',
740            'sort_key',
741            'sort_dir',
742            'sql_sort',
743            'sort_days',
744            'sql_time',
745            'firstpost_only',
746            'sql_firstpost',
747            'ex_fid_ary',
748            'sql_fora',
749            'm_approve_fid_sql',
750            'start',
751        );
752        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_author_query_before', compact($vars)));
753
754        // If the cache was completely empty count the results
755        $sql_select    = ($type == 'posts') ? 'p.post_id' : 't.topic_id';
756        $sql_select    .= $sort_by_sql[$sort_key] ? "{$sort_by_sql[$sort_key]}" : '';
757
758        // Build the query for really selecting the post_ids
759        if ($type == 'posts')
760        {
761            $sql = "SELECT $sql_select
762                FROM " . $sql_sort_table . POSTS_TABLE . ' p' . (($firstpost_only) ? ', ' . TOPICS_TABLE . ' t ' : ' ') . "
763                WHERE $sql_author
764                    $sql_topic_id
765                    $sql_firstpost
766                    $m_approve_fid_sql
767                    $sql_fora
768                    $sql_sort_join
769                    $sql_time
770                ORDER BY $sql_sort";
771            $field = 'post_id';
772        }
773        else
774        {
775            $sql = "SELECT $sql_select
776                FROM " . $sql_sort_table . TOPICS_TABLE . ' t, ' . POSTS_TABLE . " p
777                WHERE $sql_author
778                    $sql_topic_id
779                    $sql_firstpost
780                    $m_approve_fid_sql
781                    $sql_fora
782                    AND t.topic_id = p.topic_id
783                    $sql_sort_join
784                    $sql_time
785                GROUP BY $sql_select
786                ORDER BY $sql_sort";
787            $field = 'topic_id';
788        }
789
790        // Only read one block of posts from the db and then cache it
791        $result = $this->db->sql_query_limit($sql, $this->config['search_block_size'], $start);
792
793        while ($row = $this->db->sql_fetchrow($result))
794        {
795            $id_ary[] = (int) $row[$field];
796        }
797        $this->db->sql_freeresult($result);
798
799        // retrieve the total result count if needed
800        if (!$result_count)
801        {
802            $sql_found_rows = str_replace("SELECT $sql_select", "SELECT COUNT(*) as result_count", $sql);
803            $result = $this->db->sql_query($sql_found_rows);
804            $result_count = ($type == 'posts') ? (int) $this->db->sql_fetchfield('result_count') : count($this->db->sql_fetchrowset($result));
805
806            $this->db->sql_freeresult($result);
807
808            if (!$result_count)
809            {
810                return false;
811            }
812        }
813
814        if ($start >= $result_count)
815        {
816            $start = floor(($result_count - 1) / $per_page) * $per_page;
817
818            $result = $this->db->sql_query_limit($sql, $this->config['search_block_size'], $start);
819            while ($row = $this->db->sql_fetchrow($result))
820            {
821                $id_ary[] = (int) $row[$field];
822            }
823            $this->db->sql_freeresult($result);
824
825            $id_ary = array_unique($id_ary);
826        }
827
828        if (count($id_ary))
829        {
830            $this->save_ids($search_key, '', $author_ary, $result_count, $id_ary, $start, $sort_dir);
831            $id_ary = array_slice($id_ary, 0, $per_page);
832
833            return $result_count;
834        }
835        return false;
836    }
837
838    /**
839     * {@inheritdoc}
840     */
841    public function supports_phrase_search(): bool
842    {
843        return false;
844    }
845
846    /**
847     * {@inheritdoc}
848     */
849    public function index(string $mode, int $post_id, string &$message, string &$subject, int $poster_id, int $forum_id)
850    {
851        // Split old and new post/subject to obtain array of words
852        $split_text = $this->split_message($message);
853        $split_title = ($subject) ? $this->split_message($subject) : array();
854
855        $words = array_unique(array_merge($split_text, $split_title));
856
857        /**
858        * Event to modify method arguments and words before the MySQL search index is updated
859        *
860        * @event core.search_mysql_index_before
861        * @var string    mode                Contains the post mode: edit, post, reply, quote
862        * @var int        post_id                The id of the post which is modified/created
863        * @var string    message                New or updated post content
864        * @var string    subject                New or updated post subject
865        * @var int        poster_id            Post author's user id
866        * @var int        forum_id            The id of the forum in which the post is located
867        * @var array    words                List of words added to the index
868        * @var array    split_text            Array of words from the message
869        * @var array    split_title            Array of words from the title
870        * @since 3.2.3-RC1
871        */
872        $vars = array(
873            'mode',
874            'post_id',
875            'message',
876            'subject',
877            'poster_id',
878            'forum_id',
879            'words',
880            'split_text',
881            'split_title',
882        );
883        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_index_before', compact($vars)));
884
885        unset($split_text, $split_title);
886
887        // destroy cached search results containing any of the words removed or added
888        $this->destroy_cache($words, array($poster_id));
889
890        unset($words);
891    }
892
893    /**
894     * {@inheritdoc}
895     */
896    public function index_remove(array $post_ids, array $author_ids, array $forum_ids): void
897    {
898        $this->destroy_cache([], array_unique($author_ids));
899    }
900
901    /**
902     * {@inheritdoc}
903     */
904    public function tidy(): void
905    {
906        // destroy too old cached search results
907        $this->destroy_cache([]);
908
909        $this->config->set('search_last_gc', time(), false);
910    }
911
912    /**
913     * {@inheritdoc}
914     */
915    public function create_index(int &$post_counter = 0): ?array
916    {
917        // Make sure we can actually use MySQL with fulltext indexes
918        if ($error = $this->init())
919        {
920            throw new search_exception($error);
921        }
922
923        if (empty($this->stats))
924        {
925            $this->get_stats();
926        }
927
928        $alter_list = array();
929
930        if (!isset($this->stats['post_subject']))
931        {
932            $alter_entry = array();
933            $alter_entry[] = 'MODIFY post_subject varchar(255) COLLATE utf8_unicode_ci DEFAULT \'\' NOT NULL';
934            $alter_entry[] = 'ADD FULLTEXT (post_subject)';
935            $alter_list[] = $alter_entry;
936        }
937
938        if (!isset($this->stats['post_content']))
939        {
940            $alter_entry = array();
941            $alter_entry[] = 'MODIFY post_text mediumtext COLLATE utf8_unicode_ci NOT NULL';
942            $alter_entry[] = 'ADD FULLTEXT post_content (post_text, post_subject)';
943            $alter_list[] = $alter_entry;
944        }
945
946        $sql_queries = [];
947
948        foreach ($alter_list as $alter)
949        {
950            $sql_queries[] = 'ALTER TABLE ' . POSTS_TABLE . ' ' . implode(', ', $alter);
951        }
952
953        if (!isset($this->stats['post_text']))
954        {
955            $sql_queries[] = 'ALTER TABLE ' . POSTS_TABLE . ' ADD FULLTEXT post_text (post_text)';
956        }
957
958        $stats = $this->stats;
959
960        /**
961        * Event to modify SQL queries before the MySQL search index is created
962        *
963        * @event core.search_mysql_create_index_before
964        * @var array    sql_queries            Array with queries for creating the search index
965        * @var array    stats                Array with statistics of the current index (read only)
966        * @since 3.2.3-RC1
967        */
968        $vars = array(
969            'sql_queries',
970            'stats',
971        );
972        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_create_index_before', compact($vars)));
973
974        foreach ($sql_queries as $sql_query)
975        {
976            $this->db->sql_query($sql_query);
977        }
978
979        $this->db->sql_query('TRUNCATE TABLE ' . $this->search_results_table);
980
981        return null;
982    }
983
984    /**
985     * {@inheritdoc}
986     */
987    public function delete_index(int &$post_counter = null): ?array
988    {
989        // Make sure we can actually use MySQL with fulltext indexes
990        if ($error = $this->init())
991        {
992            throw new search_exception($error);
993        }
994
995        if (empty($this->stats))
996        {
997            $this->get_stats();
998        }
999
1000        $alter = array();
1001
1002        if (isset($this->stats['post_subject']))
1003        {
1004            $alter[] = 'DROP INDEX post_subject';
1005        }
1006
1007        if (isset($this->stats['post_content']))
1008        {
1009            $alter[] = 'DROP INDEX post_content';
1010        }
1011
1012        if (isset($this->stats['post_text']))
1013        {
1014            $alter[] = 'DROP INDEX post_text';
1015        }
1016
1017        $sql_queries = [];
1018
1019        if (count($alter))
1020        {
1021            $sql_queries[] = 'ALTER TABLE ' . POSTS_TABLE . ' ' . implode(', ', $alter);
1022        }
1023
1024        $stats = $this->stats;
1025
1026        /**
1027        * Event to modify SQL queries before the MySQL search index is deleted
1028        *
1029        * @event core.search_mysql_delete_index_before
1030        * @var array    sql_queries            Array with queries for deleting the search index
1031        * @var array    stats                Array with statistics of the current index (read only)
1032        * @since 3.2.3-RC1
1033        */
1034        $vars = array(
1035            'sql_queries',
1036            'stats',
1037        );
1038        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_delete_index_before', compact($vars)));
1039
1040        foreach ($sql_queries as $sql_query)
1041        {
1042            $this->db->sql_query($sql_query);
1043        }
1044
1045        $this->db->sql_query('TRUNCATE TABLE ' . $this->search_results_table);
1046
1047        return null;
1048    }
1049
1050    /**
1051     * {@inheritdoc}
1052    */
1053    public function index_created(): bool
1054    {
1055        if (empty($this->stats))
1056        {
1057            $this->get_stats();
1058        }
1059
1060        return isset($this->stats['post_subject']) && isset($this->stats['post_content']) && isset($this->stats['post_text']);
1061    }
1062
1063    /**
1064     * {@inheritdoc}
1065     */
1066    public function index_stats()
1067    {
1068        if (empty($this->stats))
1069        {
1070            $this->get_stats();
1071        }
1072
1073        return array(
1074            $this->language->lang('FULLTEXT_MYSQL_TOTAL_POSTS')            => ($this->index_created()) ? $this->stats['total_posts'] : 0,
1075        );
1076    }
1077
1078    /**
1079     * Computes the stats and store them in the $this->stats associative array
1080     */
1081    protected function get_stats()
1082    {
1083        if (strpos($this->db->get_sql_layer(), 'mysql') === false)
1084        {
1085            $this->stats = array();
1086            return;
1087        }
1088
1089        $sql = 'SHOW INDEX
1090            FROM ' . POSTS_TABLE;
1091        $result = $this->db->sql_query($sql);
1092
1093        while ($row = $this->db->sql_fetchrow($result))
1094        {
1095            // deal with older MySQL versions which didn't use Index_type
1096            $index_type = (isset($row['Index_type'])) ? $row['Index_type'] : $row['Comment'];
1097
1098            if ($index_type == 'FULLTEXT')
1099            {
1100                if ($row['Key_name'] == 'post_subject')
1101                {
1102                    $this->stats['post_subject'] = $row;
1103                }
1104                else if ($row['Key_name'] == 'post_text')
1105                {
1106                    $this->stats['post_text'] = $row;
1107                }
1108                else if ($row['Key_name'] == 'post_content')
1109                {
1110                    $this->stats['post_content'] = $row;
1111                }
1112            }
1113        }
1114        $this->db->sql_freeresult($result);
1115
1116        $this->stats['total_posts'] = empty($this->stats) ? 0 : $this->db->get_estimated_row_count(POSTS_TABLE);
1117    }
1118
1119    /**
1120     * Turns text into an array of words
1121     * @param string $text contains post text/subject
1122     *
1123     * @return array
1124     */
1125    protected function split_message($text): array
1126    {
1127        // Split words
1128        $text = preg_replace('#([^\p{L}\p{N}\'*])#u', '$1$1', str_replace('\'\'', '\' \'', trim($text)));
1129        $matches = array();
1130        preg_match_all('#(?:[^\p{L}\p{N}*]|^)([+\-|]?(?:[\p{L}\p{N}*]+\'?)*[\p{L}\p{N}*])(?:[^\p{L}\p{N}*]|$)#u', $text, $matches);
1131        $text = $matches[1];
1132
1133        // remove too short or too long words
1134        $text = array_values($text);
1135        for ($i = 0, $n = count($text); $i < $n; $i++)
1136        {
1137            $text[$i] = trim($text[$i]);
1138            if (utf8_strlen($text[$i]) < $this->config['fulltext_mysql_min_word_len'] || utf8_strlen($text[$i]) > $this->config['fulltext_mysql_max_word_len'])
1139            {
1140                unset($text[$i]);
1141            }
1142        }
1143
1144        return array_values($text);
1145    }
1146
1147    /**
1148     * {@inheritdoc}
1149     */
1150    public function get_acp_options(): array
1151    {
1152        $tpl = '
1153        <dl>
1154            <dt><label>' . $this->language->lang('MIN_SEARCH_CHARS') . $this->language->lang('COLON') . '</label><br /><span>' . $this->language->lang('FULLTEXT_MYSQL_MIN_SEARCH_CHARS_EXPLAIN') . '</span></dt>
1155            <dd>' . $this->config['fulltext_mysql_min_word_len'] . '</dd>
1156        </dl>
1157        <dl>
1158            <dt><label>' . $this->language->lang('MAX_SEARCH_CHARS') . $this->language->lang('COLON') . '</label><br /><span>' . $this->language->lang('FULLTEXT_MYSQL_MAX_SEARCH_CHARS_EXPLAIN') . '</span></dt>
1159            <dd>' . $this->config['fulltext_mysql_max_word_len'] . '</dd>
1160        </dl>
1161        ';
1162
1163        // These are fields required in the config table
1164        return array(
1165            'tpl'        => $tpl,
1166            'config'    => array()
1167        );
1168    }
1169}