Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
2.67% covered (danger)
2.67%
5 / 187
10.00% covered (danger)
10.00%
1 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
base
2.67% covered (danger)
2.67%
5 / 187
10.00% covered (danger)
10.00%
1 / 10
2947.12
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 obtain_ids
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
132
 save_ids
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
182
 destroy_cache
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
90
 create_index
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
72
 delete_index
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 forum_ids_with_indexing_enabled
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 get_posts_batch_after
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 get_max_post_id
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 get_type
0.00% covered (danger)
0.00%
0 / 1
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\cache\service;
17use phpbb\config\config;
18use phpbb\db\driver\driver_interface;
19use phpbb\user;
20
21/**
22 * optional base class for search plugins providing simple caching based on ACM
23 * and functions to retrieve ignore_words and synonyms
24 */
25abstract class base implements search_backend_interface
26{
27    public const SEARCH_RESULT_NOT_IN_CACHE = 0;
28    public const SEARCH_RESULT_IN_CACHE = 1;
29    public const SEARCH_RESULT_INCOMPLETE = 2;
30
31    // Batch size for create_index and delete_index
32    private const BATCH_SIZE = 100;
33
34    /**
35     * @var service
36     */
37    protected $cache;
38
39    /**
40     * @var config
41     */
42    protected $config;
43
44    /**
45     * @var driver_interface
46     */
47    protected $db;
48
49    /**
50     * @var user
51     */
52    protected $user;
53
54    /**
55     * @var string
56     */
57    protected $search_results_table;
58
59    /**
60     * Constructor.
61     *
62     * @param service            $cache
63     * @param config            $config
64     * @param driver_interface    $db
65     * @param user                $user
66     * @param string            $search_results_table
67     */
68    public function __construct(service $cache, config $config, driver_interface $db, user $user, string $search_results_table)
69    {
70        $this->cache = $cache;
71        $this->config = $config;
72        $this->db = $db;
73        $this->user = $user;
74        $this->search_results_table = $search_results_table;
75    }
76
77    /**
78     * Retrieves cached search results
79     *
80     * @param string $search_key an md5 string generated from all the passed search options to identify the results
81     * @param int &$result_count will contain the number of all results for the search (not only for the current page)
82     * @param array &$id_ary is filled with the ids belonging to the requested page that are stored in the cache
83     * @param int &$start indicates the first index of the page
84     * @param int $per_page number of ids each page is supposed to contain
85     * @param string $sort_dir is either a or d representing ASC and DESC
86     *
87     * @return int self::SEARCH_RESULT_NOT_IN_CACHE or self::SEARCH_RESULT_IN_CACHE or self::SEARCH_RESULT_INCOMPLETE
88     */
89    protected function obtain_ids(string $search_key, int &$result_count, array &$id_ary, int &$start, int $per_page, string $sort_dir): int
90    {
91        if (!($stored_ids = $this->cache->get('_search_results_' . $search_key)))
92        {
93            // no search results cached for this search_key
94            return self::SEARCH_RESULT_NOT_IN_CACHE;
95        }
96        else
97        {
98            $result_count = $stored_ids[-1];
99            $reverse_ids = $stored_ids[-2] != $sort_dir;
100            $complete = true;
101
102            // Change start parameter in case out of bounds
103            if ($result_count)
104            {
105                if ($start < 0)
106                {
107                    $start = 0;
108                }
109                else if ($start >= $result_count)
110                {
111                    $start = floor(($result_count - 1) / $per_page) * $per_page;
112                }
113            }
114
115            // If the sort direction differs from the direction in the cache, then recalculate array keys
116            if ($reverse_ids)
117            {
118                $keys = array_keys($stored_ids);
119                array_walk($keys, function (&$value, $key) use ($result_count)
120                    {
121                        $value = ($value >= 0) ? $result_count - $value - 1 : $value;
122                    }
123                );
124                $stored_ids = array_combine($keys, $stored_ids);
125            }
126
127            for ($i = $start, $n = $start + $per_page; ($i < $n) && ($i < $result_count); $i++)
128            {
129                if (!isset($stored_ids[$i]))
130                {
131                    $complete = false;
132                }
133                else
134                {
135                    $id_ary[] = $stored_ids[$i];
136                }
137            }
138            unset($stored_ids);
139
140            if (!$complete)
141            {
142                return self::SEARCH_RESULT_INCOMPLETE;
143            }
144            return self::SEARCH_RESULT_IN_CACHE;
145        }
146    }
147
148    /**
149     * Caches post/topic ids
150     *
151     * @param string $search_key an md5 string generated from all the passed search options to identify the results
152     * @param string $keywords contains the keywords as entered by the user
153     * @param array $author_ary an array of author ids, if the author should be ignored during the search the array is empty
154     * @param int $result_count contains the number of all results for the search (not only for the current page)
155     * @param array &$id_ary contains a list of post or topic ids that shall be cached, the first element
156     *    must have the absolute index $start in the result set.
157     * @param int $start indicates the first index of the page
158     * @param string $sort_dir is either a or d representing ASC and DESC
159     *
160     * @return void
161     */
162    protected function save_ids(string $search_key, string $keywords, array $author_ary, int $result_count, array &$id_ary, int $start, string $sort_dir): void
163    {
164        global $user;
165
166        $length = min(count($id_ary), $this->config['search_block_size']);
167
168        // nothing to cache so exit
169        if (!$length)
170        {
171            return;
172        }
173
174        $store_ids = array_slice($id_ary, 0, $length);
175        $id_range = range($start, $start + $length - 1);
176        $store_ids = array_combine($id_range, $store_ids);
177
178        // create a new resultset if there is none for this search_key yet
179        // or add the ids to the existing resultset
180        if (!($store = $this->cache->get('_search_results_' . $search_key)))
181        {
182            // add the current keywords to the recent searches in the cache which are listed on the search page
183            if (!empty($keywords) || count($author_ary))
184            {
185                $sql = 'SELECT search_time
186                    FROM ' . $this->search_results_table . '
187                    WHERE search_key = \'' . $this->db->sql_escape($search_key) . '\'';
188                $result = $this->db->sql_query($sql);
189
190                if (!$this->db->sql_fetchrow($result))
191                {
192                    $sql_ary = array(
193                        'search_key'        => $search_key,
194                        'search_time'        => time(),
195                        'search_keywords'    => $keywords,
196                        'search_authors'    => ' ' . implode(' ', $author_ary) . ' '
197                    );
198
199                    $sql = 'INSERT INTO ' . $this->search_results_table . ' ' . $this->db->sql_build_array('INSERT', $sql_ary);
200                    $this->db->sql_query($sql);
201                }
202                $this->db->sql_freeresult($result);
203            }
204
205            $sql = 'UPDATE ' . USERS_TABLE . '
206                SET user_last_search = ' . time() . '
207                WHERE user_id = ' . $user->data['user_id'];
208            $this->db->sql_query($sql);
209
210            $store = array(-1 => $result_count, -2 => $sort_dir);
211        }
212        else
213        {
214            // we use one set of results for both sort directions so we have to calculate the indizes
215            // for the reversed array
216            if ($store[-2] != $sort_dir)
217            {
218                $keys = array_keys($store_ids);
219                array_walk($keys, function (&$value, $key) use ($store) {
220                    $value = $store[-1] - $value - 1;
221                });
222                $store_ids = array_combine($keys, $store_ids);
223            }
224        }
225
226        // append the ids
227        if (is_array($store_ids))
228        {
229            $store += $store_ids;
230            ksort($store);
231
232            // if the cache is too big
233            if (count($store) - 2 > 20 * $this->config['search_block_size'])
234            {
235                // remove everything in front of two blocks in front of the current start index
236                for ($i = 0, $n = $id_range[0] - 2 * $this->config['search_block_size']; $i < $n; $i++)
237                {
238                    if (isset($store[$i]))
239                    {
240                        unset($store[$i]);
241                    }
242                }
243
244                // remove everything after two blocks after the current stop index
245                end($id_range);
246                for ($i = $store[-1] - 1, $n = current($id_range) + 2 * $this->config['search_block_size']; $i > $n; $i--)
247                {
248                    if (isset($store[$i]))
249                    {
250                        unset($store[$i]);
251                    }
252                }
253            }
254            $this->cache->put('_search_results_' . $search_key, $store, $this->config['search_store_results']);
255
256            $sql = 'UPDATE ' . $this->search_results_table . '
257                SET search_time = ' . time() . '
258                WHERE search_key = \'' . $this->db->sql_escape($search_key) . '\'';
259            $this->db->sql_query($sql);
260        }
261
262        unset($store, $store_ids, $id_range);
263    }
264
265    /**
266     * Removes old entries from the search results table and removes searches with keywords that contain a word in $words.
267     *
268     * @param array $words
269     * @param array|bool $authors
270     */
271    protected function destroy_cache(array $words, $authors = false): void
272    {
273        // clear all searches that searched for the specified words
274        if (count($words))
275        {
276            $sql_where = '';
277            foreach ($words as $word)
278            {
279                $sql_where .= " OR search_keywords " . $this->db->sql_like_expression($this->db->get_any_char() . $word . $this->db->get_any_char());
280            }
281
282            $sql = 'SELECT search_key
283                FROM ' . $this->search_results_table . "
284                WHERE search_keywords LIKE '%*%' $sql_where";
285            $result = $this->db->sql_query($sql);
286
287            while ($row = $this->db->sql_fetchrow($result))
288            {
289                $this->cache->destroy('_search_results_' . $row['search_key']);
290            }
291            $this->db->sql_freeresult($result);
292        }
293
294        // clear all searches that searched for the specified authors
295        if (is_array($authors) && count($authors))
296        {
297            $sql_where = '';
298            foreach ($authors as $author)
299            {
300                $sql_where .= (($sql_where) ? ' OR ' : '') . 'search_authors ' . $this->db->sql_like_expression($this->db->get_any_char() . ' ' . (int) $author . ' ' . $this->db->get_any_char());
301            }
302
303            $sql = 'SELECT search_key
304                FROM ' . $this->search_results_table . "
305                WHERE $sql_where";
306            $result = $this->db->sql_query($sql);
307
308            while ($row = $this->db->sql_fetchrow($result))
309            {
310                $this->cache->destroy('_search_results_' . $row['search_key']);
311            }
312            $this->db->sql_freeresult($result);
313        }
314
315        $sql = 'DELETE
316            FROM ' . $this->search_results_table . '
317            WHERE search_time < ' . (time() - (int) $this->config['search_store_results']);
318        $this->db->sql_query($sql);
319    }
320
321    /**
322     * {@inheritdoc}
323     */
324    public function create_index(int &$post_counter = 0): array|null
325    {
326        $max_post_id = $this->get_max_post_id();
327        $forums_indexing_enabled = $this->forum_ids_with_indexing_enabled();
328
329        $starttime = microtime(true);
330        $row_count = 0;
331
332        while (still_on_time() && $post_counter < $max_post_id)
333        {
334            $rows = $this->get_posts_batch_after($post_counter);
335
336            if ($this->db->sql_buffer_nested_transactions())
337            {
338                $rows = iterator_to_array($rows);
339            }
340
341            foreach ($rows as $row)
342            {
343                // Indexing enabled for this forum
344                if (in_array($row['forum_id'], $forums_indexing_enabled, true))
345                {
346                    $this->index('post', (int) $row['post_id'], $row['post_text'], $row['post_subject'], (int) $row['poster_id'], (int) $row['forum_id']);
347                }
348                $row_count++;
349                $post_counter = (int) $row['post_id'];
350            }
351
352            // With cli process only one batch each time to be able to track progress
353            if (PHP_SAPI === 'cli')
354            {
355                break;
356            }
357        }
358
359        // pretend the number of posts was as big as the number of ids we indexed so far
360        // just an estimation as it includes deleted posts
361        $num_posts = $this->config['num_posts'];
362        $this->config['num_posts'] = min($this->config['num_posts'], $post_counter);
363        $this->tidy();
364        $this->config['num_posts'] = $num_posts;
365
366        if ($post_counter < $max_post_id) // If there are still post to index
367        {
368            $totaltime = microtime(true) - $starttime;
369            $rows_per_second = $row_count / $totaltime;
370
371            return [
372                'row_count' => $row_count,
373                'post_counter' => $post_counter,
374                'max_post_id' => $max_post_id,
375                'rows_per_second' => $rows_per_second,
376            ];
377        }
378
379        return null;
380    }
381
382    /**
383     * {@inheritdoc}
384     */
385    public function delete_index(int|null &$post_counter = null): array|null
386    {
387        $max_post_id = $this->get_max_post_id();
388
389        $starttime = microtime(true);
390        $row_count = 0;
391
392        while (still_on_time() && $post_counter < $max_post_id)
393        {
394            $rows = $this->get_posts_batch_after($post_counter);
395            $ids = $posters = $forum_ids = array();
396            foreach ($rows as $row)
397            {
398                $ids[] = $row['post_id'];
399                $posters[] = $row['poster_id'];
400                $forum_ids[] = $row['forum_id'];
401            }
402            $row_count += count($ids);
403
404            if (count($ids))
405            {
406                $this->index_remove($ids, $posters, $forum_ids);
407                $post_counter = $ids[count($ids) - 1];
408            }
409
410            // With cli process only one batch each time to be able to track progress
411            if (PHP_SAPI === 'cli')
412            {
413                break;
414            }
415        }
416
417        if ($post_counter < $max_post_id) // If there are still post delete from index
418        {
419            $totaltime = microtime(true) - $starttime;
420            $rows_per_second = $row_count / $totaltime;
421
422            return [
423                'row_count' => $row_count,
424                'post_counter' => $post_counter,
425                'max_post_id' => $max_post_id,
426                'rows_per_second' => $rows_per_second,
427            ];
428        }
429
430        return null;
431    }
432
433    /**
434     * Return the ids of the forums that have indexing enabled
435     *
436     * @return array
437     */
438    protected function forum_ids_with_indexing_enabled(): array
439    {
440        $forums = [];
441
442        $sql = 'SELECT forum_id, enable_indexing
443            FROM ' . FORUMS_TABLE;
444        $result = $this->db->sql_query($sql, 3600);
445
446        while ($row = $this->db->sql_fetchrow($result))
447        {
448            if ((bool) $row['enable_indexing'])
449            {
450                $forums[] = $row['forum_id'];
451            }
452        }
453        $this->db->sql_freeresult($result);
454
455        return $forums;
456    }
457
458    /**
459     * Get batch of posts after id
460     *
461     * @param int $post_id
462     * @return \Generator
463     */
464    protected function get_posts_batch_after(int $post_id): \Generator
465    {
466        $sql = 'SELECT post_id, post_subject, post_text, poster_id, forum_id
467                FROM ' . POSTS_TABLE . '
468                WHERE post_id > ' . (int) $post_id . '
469                ORDER BY post_id ASC';
470        $result = $this->db->sql_query_limit($sql, self::BATCH_SIZE);
471
472        while ($row = $this->db->sql_fetchrow($result))
473        {
474            yield $row;
475        }
476
477        $this->db->sql_freeresult($result);
478    }
479
480    /**
481     * Get post with higher id
482     */
483    protected function get_max_post_id(): int
484    {
485        $sql = 'SELECT MAX(post_id) as max_post_id
486            FROM '. POSTS_TABLE;
487        $result = $this->db->sql_query($sql);
488        $max_post_id = (int) $this->db->sql_fetchfield('max_post_id');
489        $this->db->sql_freeresult($result);
490
491        return $max_post_id;
492    }
493
494    /**
495     * {@inheritdoc}
496     */
497    public function get_type(): string
498    {
499        return static::class;
500    }
501}