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