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
15846.11
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 / 131
0.00% covered (danger)
0.00%
0 / 1
992
 author_search
0.00% covered (danger)
0.00%
0 / 133
0.00% covered (danger)
0.00%
0 / 1
992
 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        // if the total result count is not cached yet, retrieve it from the db
554        if (!$result_count && count($id_ary))
555        {
556            $sql_found_rows = str_replace("SELECT $sql_select", "SELECT COUNT($sql_select) as result_count", $sql);
557            $result = $this->db->sql_query($sql_found_rows);
558            $result_count = (int) $this->db->sql_fetchfield('result_count');
559            $this->db->sql_freeresult($result);
560
561            if (!$result_count)
562            {
563                return false;
564            }
565        }
566
567        if ($start >= $result_count)
568        {
569            $start = floor(($result_count - 1) / $per_page) * $per_page;
570
571            $result = $this->db->sql_query_limit($sql, $this->config['search_block_size'], $start);
572
573            while ($row = $this->db->sql_fetchrow($result))
574            {
575                $id_ary[] = (int) $row[$field];
576            }
577            $this->db->sql_freeresult($result);
578        }
579
580        $id_ary = array_unique($id_ary);
581
582        // store the ids, from start on then delete anything that isn't on the current page because we only need ids for one page
583        $this->save_ids($search_key, implode(' ', $this->split_words), $author_ary, $result_count, $id_ary, $start, $sort_dir);
584        $id_ary = array_slice($id_ary, 0, (int) $per_page);
585
586        return $result_count;
587    }
588
589    /**
590     * {@inheritdoc}
591     */
592    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)
593    {
594        // No author? No posts
595        if (!count($author_ary))
596        {
597            return 0;
598        }
599
600        // generate a search_key from all the options to identify the results
601        $search_key_array = array(
602            '',
603            $type,
604            ($firstpost_only) ? 'firstpost' : '',
605            '',
606            '',
607            $sort_days,
608            $sort_key,
609            $topic_id,
610            implode(',', $ex_fid_ary),
611            $post_visibility,
612            implode(',', $author_ary),
613            $author_name,
614        );
615
616        /**
617        * Allow changing the search_key for cached results
618        *
619        * @event core.search_mysql_by_author_modify_search_key
620        * @var    array    search_key_array    Array with search parameters to generate the search_key
621        * @var    string    type                Searching type ('posts', 'topics')
622        * @var    boolean    firstpost_only        Flag indicating if only topic starting posts are considered
623        * @var    int        sort_days            Time, in days, of the oldest possible post to list
624        * @var    string    sort_key            The sort type used from the possible sort types
625        * @var    int        topic_id            Limit the search to this topic_id only
626        * @var    array    ex_fid_ary            Which forums not to search on
627        * @var    string    post_visibility        Post visibility data
628        * @var    array    author_ary            Array of user_id containing the users to filter the results to
629        * @var    string    author_name            The username to search on
630        * @since 3.1.7-RC1
631        */
632        $vars = array(
633            'search_key_array',
634            'type',
635            'firstpost_only',
636            'sort_days',
637            'sort_key',
638            'topic_id',
639            'ex_fid_ary',
640            'post_visibility',
641            'author_ary',
642            'author_name',
643        );
644        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_by_author_modify_search_key', compact($vars)));
645
646        $search_key = md5(implode('#', $search_key_array));
647
648        if ($start < 0)
649        {
650            $start = 0;
651        }
652
653        // try reading the results from cache
654        $result_count = 0;
655        if ($this->obtain_ids($search_key, $result_count, $id_ary, $start, $per_page, $sort_dir) == self::SEARCH_RESULT_IN_CACHE)
656        {
657            return $result_count;
658        }
659
660        $id_ary = array();
661
662        // Create some display specific sql strings
663        if ($author_name)
664        {
665            // first one matches post of registered users, second one guests and deleted users
666            $sql_author = '(' . $this->db->sql_in_set('p.poster_id', array_diff($author_ary, array(ANONYMOUS)), false, true) . ' OR p.post_username ' . $author_name . ')';
667        }
668        else
669        {
670            $sql_author = $this->db->sql_in_set('p.poster_id', $author_ary);
671        }
672        $sql_fora        = (count($ex_fid_ary)) ? ' AND ' . $this->db->sql_in_set('p.forum_id', $ex_fid_ary, true) : '';
673        $sql_topic_id    = ($topic_id) ? ' AND p.topic_id = ' . (int) $topic_id : '';
674        $sql_time        = ($sort_days) ? ' AND p.post_time >= ' . (time() - ($sort_days * 86400)) : '';
675        $sql_firstpost = ($firstpost_only) ? ' AND p.post_id = t.topic_first_post_id' : '';
676
677        // Build sql strings for sorting
678        $sql_sort = $sort_by_sql[$sort_key] . (($sort_dir == 'a') ? ' ASC' : ' DESC');
679        $sql_sort_table = $sql_sort_join = '';
680        switch ($sql_sort[0])
681        {
682            case 'u':
683                $sql_sort_table    = USERS_TABLE . ' u, ';
684                $sql_sort_join    = ($type == 'posts') ? ' AND u.user_id = p.poster_id ' : ' AND u.user_id = t.topic_poster ';
685            break;
686
687            case 't':
688                $sql_sort_table    = ($type == 'posts' && !$firstpost_only) ? TOPICS_TABLE . ' t, ' : '';
689                $sql_sort_join    = ($type == 'posts' && !$firstpost_only) ? ' AND t.topic_id = p.topic_id ' : '';
690            break;
691
692            case 'f':
693                $sql_sort_table    = FORUMS_TABLE . ' f, ';
694                $sql_sort_join    = ' AND f.forum_id = p.forum_id ';
695            break;
696        }
697
698        $m_approve_fid_sql = ' AND ' . $post_visibility;
699
700        /**
701        * Allow changing the query used to search for posts by author in fulltext_mysql
702        *
703        * @event core.search_mysql_author_query_before
704        * @var    int        result_count        The previous result count for the format of the query.
705        *                                    Set to 0 to force a re-count
706        * @var    string    sql_sort_table        CROSS JOIN'ed table to allow doing the sort chosen
707        * @var    string    sql_sort_join        Condition to define how to join the CROSS JOIN'ed table specifyed in sql_sort_table
708        * @var    string    type                Either "posts" or "topics" specifying the type of search being made
709        * @var    array    author_ary            Array of user_id containing the users to filter the results to
710        * @var    string    author_name            An extra username to search on
711        * @var    string    sql_author            SQL WHERE condition for the post author ids
712        * @var    int        topic_id            Limit the search to this topic_id only
713        * @var    string    sql_topic_id        SQL of topic_id
714        * @var    string    sort_by_sql            The possible predefined sort types
715        * @var    string    sort_key            The sort type used from the possible sort types
716        * @var    string    sort_dir            "a" for ASC or "d" dor DESC for the sort order used
717        * @var    string    sql_sort            The result SQL when processing sort_by_sql + sort_key + sort_dir
718        * @var    string    sort_days            Time, in days, that the oldest post showing can have
719        * @var    string    sql_time            The SQL to search on the time specifyed by sort_days
720        * @var    bool    firstpost_only        Wether or not to search only on the first post of the topics
721        * @var    string    sql_firstpost        The SQL with the conditions to join the tables when using firstpost_only
722        * @var    array    ex_fid_ary            Forum ids that must not be searched on
723        * @var    array    sql_fora            SQL query for ex_fid_ary
724        * @var    string    m_approve_fid_sql    WHERE clause condition on post_visibility restrictions
725        * @var    int        start                How many posts to skip in the search results (used for pagination)
726        * @since 3.1.5-RC1
727        */
728        $vars = array(
729            'result_count',
730            'sql_sort_table',
731            'sql_sort_join',
732            'type',
733            'author_ary',
734            'author_name',
735            'sql_author',
736            'topic_id',
737            'sql_topic_id',
738            'sort_by_sql',
739            'sort_key',
740            'sort_dir',
741            'sql_sort',
742            'sort_days',
743            'sql_time',
744            'firstpost_only',
745            'sql_firstpost',
746            'ex_fid_ary',
747            'sql_fora',
748            'm_approve_fid_sql',
749            'start',
750        );
751        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_author_query_before', compact($vars)));
752
753        // If the cache was completely empty count the results
754        $sql_select    = ($type == 'posts') ? 'p.post_id' : 't.topic_id';
755        $sql_select    .= $sort_by_sql[$sort_key] ? "{$sort_by_sql[$sort_key]}" : '';
756
757        // Build the query for really selecting the post_ids
758        if ($type == 'posts')
759        {
760            // For sorting by non-unique columns, add unique sort key to avoid duplicated rows in results
761            $sql_sort .= ', p.post_id' . (($sort_dir == 'a') ? ' ASC' : ' DESC');
762            $sql = "SELECT $sql_select
763                FROM " . $sql_sort_table . POSTS_TABLE . ' p' . (($firstpost_only) ? ', ' . TOPICS_TABLE . ' t ' : ' ') . "
764                WHERE $sql_author
765                    $sql_topic_id
766                    $sql_firstpost
767                    $m_approve_fid_sql
768                    $sql_fora
769                    $sql_sort_join
770                    $sql_time
771                ORDER BY $sql_sort";
772            $field = 'post_id';
773        }
774        else
775        {
776            $sql = "SELECT $sql_select
777                FROM " . $sql_sort_table . TOPICS_TABLE . ' t, ' . POSTS_TABLE . " p
778                WHERE $sql_author
779                    $sql_topic_id
780                    $sql_firstpost
781                    $m_approve_fid_sql
782                    $sql_fora
783                    AND t.topic_id = p.topic_id
784                    $sql_sort_join
785                    $sql_time
786                GROUP BY $sql_select
787                ORDER BY $sql_sort";
788            $field = 'topic_id';
789        }
790
791        // Only read one block of posts from the db and then cache it
792        $result = $this->db->sql_query_limit($sql, $this->config['search_block_size'], $start);
793
794        while ($row = $this->db->sql_fetchrow($result))
795        {
796            $id_ary[] = (int) $row[$field];
797        }
798        $this->db->sql_freeresult($result);
799
800        // retrieve the total result count if needed
801        if (!$result_count)
802        {
803            $sql_found_rows = str_replace("SELECT $sql_select", "SELECT COUNT(*) as result_count", $sql);
804            $result = $this->db->sql_query($sql_found_rows);
805            $result_count = ($type == 'posts') ? (int) $this->db->sql_fetchfield('result_count') : count($this->db->sql_fetchrowset($result));
806
807            $this->db->sql_freeresult($result);
808
809            if (!$result_count)
810            {
811                return false;
812            }
813        }
814
815        if ($start >= $result_count)
816        {
817            $start = floor(($result_count - 1) / $per_page) * $per_page;
818
819            $result = $this->db->sql_query_limit($sql, $this->config['search_block_size'], $start);
820            while ($row = $this->db->sql_fetchrow($result))
821            {
822                $id_ary[] = (int) $row[$field];
823            }
824            $this->db->sql_freeresult($result);
825        }
826
827        $id_ary = array_unique($id_ary);
828
829        if (count($id_ary))
830        {
831            $this->save_ids($search_key, '', $author_ary, $result_count, $id_ary, $start, $sort_dir);
832            $id_ary = array_slice($id_ary, 0, $per_page);
833
834            return $result_count;
835        }
836        return false;
837    }
838
839    /**
840     * {@inheritdoc}
841     */
842    public function supports_phrase_search(): bool
843    {
844        return false;
845    }
846
847    /**
848     * {@inheritdoc}
849     */
850    public function index(string $mode, int $post_id, string &$message, string &$subject, int $poster_id, int $forum_id)
851    {
852        // Split old and new post/subject to obtain array of words
853        $split_text = $this->split_message($message);
854        $split_title = ($subject) ? $this->split_message($subject) : array();
855
856        $words = array_unique(array_merge($split_text, $split_title));
857
858        /**
859        * Event to modify method arguments and words before the MySQL search index is updated
860        *
861        * @event core.search_mysql_index_before
862        * @var string    mode                Contains the post mode: edit, post, reply, quote
863        * @var int        post_id                The id of the post which is modified/created
864        * @var string    message                New or updated post content
865        * @var string    subject                New or updated post subject
866        * @var int        poster_id            Post author's user id
867        * @var int        forum_id            The id of the forum in which the post is located
868        * @var array    words                List of words added to the index
869        * @var array    split_text            Array of words from the message
870        * @var array    split_title            Array of words from the title
871        * @since 3.2.3-RC1
872        */
873        $vars = array(
874            'mode',
875            'post_id',
876            'message',
877            'subject',
878            'poster_id',
879            'forum_id',
880            'words',
881            'split_text',
882            'split_title',
883        );
884        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_index_before', compact($vars)));
885
886        unset($split_text, $split_title);
887
888        // destroy cached search results containing any of the words removed or added
889        $this->destroy_cache($words, array($poster_id));
890
891        unset($words);
892    }
893
894    /**
895     * {@inheritdoc}
896     */
897    public function index_remove(array $post_ids, array $author_ids, array $forum_ids): void
898    {
899        $this->destroy_cache([], array_unique($author_ids));
900    }
901
902    /**
903     * {@inheritdoc}
904     */
905    public function tidy(): void
906    {
907        // destroy too old cached search results
908        $this->destroy_cache([]);
909
910        $this->config->set('search_last_gc', time(), false);
911    }
912
913    /**
914     * {@inheritdoc}
915     */
916    public function create_index(int &$post_counter = 0): array|null
917    {
918        // Make sure we can actually use MySQL with fulltext indexes
919        if ($error = $this->init())
920        {
921            throw new search_exception($error);
922        }
923
924        if (empty($this->stats))
925        {
926            $this->get_stats();
927        }
928
929        $alter_list = array();
930
931        if (!isset($this->stats['post_subject']))
932        {
933            $alter_entry = array();
934            $alter_entry[] = 'MODIFY post_subject varchar(255) COLLATE utf8_unicode_ci DEFAULT \'\' NOT NULL';
935            $alter_entry[] = 'ADD FULLTEXT (post_subject)';
936            $alter_list[] = $alter_entry;
937        }
938
939        if (!isset($this->stats['post_content']))
940        {
941            $alter_entry = array();
942            $alter_entry[] = 'MODIFY post_text mediumtext COLLATE utf8_unicode_ci NOT NULL';
943            $alter_entry[] = 'ADD FULLTEXT post_content (post_text, post_subject)';
944            $alter_list[] = $alter_entry;
945        }
946
947        $sql_queries = [];
948
949        foreach ($alter_list as $alter)
950        {
951            $sql_queries[] = 'ALTER TABLE ' . POSTS_TABLE . ' ' . implode(', ', $alter);
952        }
953
954        if (!isset($this->stats['post_text']))
955        {
956            $sql_queries[] = 'ALTER TABLE ' . POSTS_TABLE . ' ADD FULLTEXT post_text (post_text)';
957        }
958
959        $stats = $this->stats;
960
961        /**
962        * Event to modify SQL queries before the MySQL search index is created
963        *
964        * @event core.search_mysql_create_index_before
965        * @var array    sql_queries            Array with queries for creating the search index
966        * @var array    stats                Array with statistics of the current index (read only)
967        * @since 3.2.3-RC1
968        */
969        $vars = array(
970            'sql_queries',
971            'stats',
972        );
973        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_create_index_before', compact($vars)));
974
975        foreach ($sql_queries as $sql_query)
976        {
977            $this->db->sql_query($sql_query);
978        }
979
980        $this->db->sql_query('TRUNCATE TABLE ' . $this->search_results_table);
981
982        return null;
983    }
984
985    /**
986     * {@inheritdoc}
987     */
988    public function delete_index(int|null &$post_counter = null): array|null
989    {
990        // Make sure we can actually use MySQL with fulltext indexes
991        if ($error = $this->init())
992        {
993            throw new search_exception($error);
994        }
995
996        if (empty($this->stats))
997        {
998            $this->get_stats();
999        }
1000
1001        $alter = array();
1002
1003        if (isset($this->stats['post_subject']))
1004        {
1005            $alter[] = 'DROP INDEX post_subject';
1006        }
1007
1008        if (isset($this->stats['post_content']))
1009        {
1010            $alter[] = 'DROP INDEX post_content';
1011        }
1012
1013        if (isset($this->stats['post_text']))
1014        {
1015            $alter[] = 'DROP INDEX post_text';
1016        }
1017
1018        $sql_queries = [];
1019
1020        if (count($alter))
1021        {
1022            $sql_queries[] = 'ALTER TABLE ' . POSTS_TABLE . ' ' . implode(', ', $alter);
1023        }
1024
1025        $stats = $this->stats;
1026
1027        /**
1028        * Event to modify SQL queries before the MySQL search index is deleted
1029        *
1030        * @event core.search_mysql_delete_index_before
1031        * @var array    sql_queries            Array with queries for deleting the search index
1032        * @var array    stats                Array with statistics of the current index (read only)
1033        * @since 3.2.3-RC1
1034        */
1035        $vars = array(
1036            'sql_queries',
1037            'stats',
1038        );
1039        extract($this->phpbb_dispatcher->trigger_event('core.search_mysql_delete_index_before', compact($vars)));
1040
1041        foreach ($sql_queries as $sql_query)
1042        {
1043            $this->db->sql_query($sql_query);
1044        }
1045
1046        $this->db->sql_query('TRUNCATE TABLE ' . $this->search_results_table);
1047
1048        return null;
1049    }
1050
1051    /**
1052     * {@inheritdoc}
1053    */
1054    public function index_created(): bool
1055    {
1056        if (empty($this->stats))
1057        {
1058            $this->get_stats();
1059        }
1060
1061        return isset($this->stats['post_subject']) && isset($this->stats['post_content']) && isset($this->stats['post_text']);
1062    }
1063
1064    /**
1065     * {@inheritdoc}
1066     */
1067    public function index_stats()
1068    {
1069        if (empty($this->stats))
1070        {
1071            $this->get_stats();
1072        }
1073
1074        return array(
1075            $this->language->lang('FULLTEXT_MYSQL_TOTAL_POSTS')            => ($this->index_created()) ? $this->stats['total_posts'] : 0,
1076        );
1077    }
1078
1079    /**
1080     * Computes the stats and store them in the $this->stats associative array
1081     */
1082    protected function get_stats()
1083    {
1084        if (strpos($this->db->get_sql_layer(), 'mysql') === false)
1085        {
1086            $this->stats = array();
1087            return;
1088        }
1089
1090        $sql = 'SHOW INDEX
1091            FROM ' . POSTS_TABLE;
1092        $result = $this->db->sql_query($sql);
1093
1094        while ($row = $this->db->sql_fetchrow($result))
1095        {
1096            // deal with older MySQL versions which didn't use Index_type
1097            $index_type = (isset($row['Index_type'])) ? $row['Index_type'] : $row['Comment'];
1098
1099            if ($index_type == 'FULLTEXT')
1100            {
1101                if ($row['Key_name'] == 'post_subject')
1102                {
1103                    $this->stats['post_subject'] = $row;
1104                }
1105                else if ($row['Key_name'] == 'post_text')
1106                {
1107                    $this->stats['post_text'] = $row;
1108                }
1109                else if ($row['Key_name'] == 'post_content')
1110                {
1111                    $this->stats['post_content'] = $row;
1112                }
1113            }
1114        }
1115        $this->db->sql_freeresult($result);
1116
1117        $this->stats['total_posts'] = empty($this->stats) ? 0 : $this->db->get_estimated_row_count(POSTS_TABLE);
1118    }
1119
1120    /**
1121     * Turns text into an array of words
1122     * @param string $text contains post text/subject
1123     *
1124     * @return array
1125     */
1126    protected function split_message($text): array
1127    {
1128        // Split words
1129        $text = preg_replace('#([^\p{L}\p{N}\'*])#u', '$1$1', str_replace('\'\'', '\' \'', trim($text)));
1130        $matches = array();
1131        preg_match_all('#(?:[^\p{L}\p{N}*]|^)([+\-|]?(?:[\p{L}\p{N}*]+\'?)*[\p{L}\p{N}*])(?:[^\p{L}\p{N}*]|$)#u', $text, $matches);
1132        $text = $matches[1];
1133
1134        // remove too short or too long words
1135        $text = array_values($text);
1136        for ($i = 0, $n = count($text); $i < $n; $i++)
1137        {
1138            $text[$i] = trim($text[$i]);
1139            if (utf8_strlen($text[$i]) < $this->config['fulltext_mysql_min_word_len'] || utf8_strlen($text[$i]) > $this->config['fulltext_mysql_max_word_len'])
1140            {
1141                unset($text[$i]);
1142            }
1143        }
1144
1145        return array_values($text);
1146    }
1147
1148    /**
1149     * {@inheritdoc}
1150     */
1151    public function get_acp_options(): array
1152    {
1153        $tpl = '
1154        <dl>
1155            <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>
1156            <dd>' . $this->config['fulltext_mysql_min_word_len'] . '</dd>
1157        </dl>
1158        <dl>
1159            <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>
1160            <dd>' . $this->config['fulltext_mysql_max_word_len'] . '</dd>
1161        </dl>
1162        ';
1163
1164        // These are fields required in the config table
1165        return array(
1166            'tpl'        => $tpl,
1167            'config'    => array()
1168        );
1169    }
1170}