Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.71% covered (success)
89.71%
157 / 175
77.78% covered (warning)
77.78%
14 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Search
89.71% covered (success)
89.71%
157 / 175
77.78% covered (warning)
77.78%
14 / 18
69.60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCategoryId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCategoryId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 search
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 autoComplete
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
3.51
 searchDatabase
95.65% covered (success)
95.65%
44 / 46
0.00% covered (danger)
0.00%
0 / 1
10
 resolveSearchDatabaseType
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 getDatabaseDriverClassName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 searchCustomPages
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
8
 searchElasticsearch
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 searchOpenSearch
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 logSearchTerm
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 deleteSearchTermById
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deleteAllSearchTerms
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMostPopularSearches
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
12
 getSearchesCount
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setCategory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCategory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The phpMyFAQ Search class.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @author    Matteo Scaramuccia <matteo@scaramuccia.com>
13 * @author    Adrianna Musiol <musiol@imageaccess.de>
14 * @copyright 2008-2026 phpMyFAQ Team
15 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2008-01-26
18 */
19
20declare(strict_types=1);
21
22namespace phpMyFAQ;
23
24use DateTime;
25use Exception;
26use phpMyFAQ\Database\DatabaseDriver;
27use phpMyFAQ\Search\Search\Elasticsearch;
28use phpMyFAQ\Search\Search\OpenSearch;
29use phpMyFAQ\Search\SearchFactory;
30use stdClass;
31
32/**
33 * Class Search
34 *
35 * @package phpMyFAQ
36 */
37class Search
38{
39    private ?int $categoryId = null;
40
41    private ?Category $category = null;
42
43    private readonly string $table;
44
45    /**
46     * Constructor.
47     */
48    public function __construct(
49        private readonly Configuration $configuration,
50    ) {
51        $this->table = Database::getTablePrefix() . 'faqsearches';
52    }
53
54    /**
55     * Setter for category.
56     *
57     * @param int|null $categoryId Entity ID
58     */
59    public function setCategoryId(?int $categoryId): void
60    {
61        $this->categoryId = $categoryId;
62    }
63
64    /**
65     * Getter for category.
66     */
67    public function getCategoryId(): ?int
68    {
69        return $this->categoryId;
70    }
71
72    /**
73     * The search function to handle the different search engines.
74     *
75     * @param string $searchTerm Text/Number (solution id)
76     * @param bool   $allLanguages true to search over all languages
77     * @return array<array-key, \stdClass>
78     * @throws Exception
79     */
80    public function search(string $searchTerm, bool $allLanguages = true): array
81    {
82        if (
83            is_numeric($searchTerm)
84            && (int) $searchTerm >= PMF_SOLUTION_ID_START_VALUE
85            && true === $this->configuration->get(item: 'search.searchForSolutionId')
86        ) {
87            return $this->searchDatabase($searchTerm, $allLanguages);
88        }
89
90        if (true === $this->configuration->get(item: 'search.enableElasticsearch')) {
91            return $this->searchElasticsearch($searchTerm, $allLanguages);
92        }
93
94        if (true === $this->configuration->get(item: 'search.enableOpenSearch')) {
95            return $this->searchOpenSearch($searchTerm, $allLanguages);
96        }
97
98        return $this->searchDatabase($searchTerm, $allLanguages);
99    }
100
101    /**
102     * The auto complete function to handle the different search engines.
103     *
104     * @param string $searchTerm Text to auto complete
105     * @throws Exception
106     * @return \stdClass[]
107     */
108    public function autoComplete(string $searchTerm): array
109    {
110        if ($this->configuration->get(item: 'search.enableElasticsearch')) {
111            $elasticsearch = new Elasticsearch($this->configuration);
112            $allCategories = $this->getCategory()->getAllCategoryIds();
113
114            $elasticsearch->setCategoryIds($allCategories);
115            $elasticsearch->setLanguage($this->configuration->getLanguage()->getLanguage());
116
117            // Elasticsearch autoComplete now includes custom pages from the index
118            return $elasticsearch->autoComplete($searchTerm);
119        }
120
121        if ($this->configuration->get(item: 'search.enableOpenSearch')) {
122            $opensearch = new OpenSearch($this->configuration);
123            $allCategories = $this->getCategory()->getAllCategoryIds();
124
125            $opensearch->setCategoryIds($allCategories);
126            $opensearch->setLanguage($this->configuration->getLanguage()->getLanguage());
127
128            // OpenSearch autoComplete will include custom pages once indexed
129            return $opensearch->autoComplete($searchTerm);
130        }
131
132        return $this->searchDatabase($searchTerm, false);
133    }
134
135    /**
136     * The search function for the database powered full-text search.
137     *
138     * @param string $searchTerm Text/Number (solution id)
139     * @param bool   $allLanguages true to search over all languages
140     * @return array<array-key, \stdClass>
141     * @throws Exception
142     */
143    public function searchDatabase(string $searchTerm, bool $allLanguages = true): array
144    {
145        $fdTable = Database::getTablePrefix() . 'faqdata AS fd';
146        $fcrTable = Database::getTablePrefix() . 'faqcategoryrelations';
147        $condition = ['fd.active' => 'yes'];
148        $searchDatabase = SearchFactory::create($this->configuration, [
149            'database' => $this->resolveSearchDatabaseType(),
150        ]);
151
152        $categoryId = $this->getCategoryId();
153        if ($categoryId !== null && 0 < $categoryId) {
154            $category = $this->getCategory();
155            $selectedCategory = [
156                $fcrTable . '.category_id' => $category instanceof Category
157                    ? array_merge([$categoryId], $category->getChildNodes($categoryId))
158                    : [$categoryId],
159            ];
160
161            $condition = [...$selectedCategory, ...$condition];
162        }
163
164        if (!$allLanguages && !is_numeric($searchTerm)) {
165            $selectedLanguage = ['fd.lang' => $this->configuration->getLanguage()->getLanguage()];
166            $condition = [...$selectedLanguage, ...$condition];
167        }
168
169        $searchDatabase
170            ->setTable($fdTable)
171            ->setResultColumns([
172                'fd.id AS id',
173                'fd.lang AS lang',
174                'fd.solution_id AS solution_id',
175                $fcrTable . '.category_id AS category_id',
176                'fd.thema AS question',
177                'fd.content AS answer',
178            ])
179            ->setJoinedTable($fcrTable)
180            ->setJoinedColumns([
181                'fd.id = ' . $fcrTable . '.record_id',
182                'fd.lang = ' . $fcrTable . '.record_lang',
183            ])
184            ->setConditions($condition);
185
186        if (is_numeric($searchTerm)) {
187            $searchDatabase->setMatchingColumns(['fd.solution_id']);
188        }
189
190        if (!is_numeric($searchTerm)) {
191            $searchDatabase->setMatchingColumns(['fd.thema', 'fd.content', 'fd.keywords']);
192        }
193
194        $result = $searchDatabase->search($searchTerm);
195
196        $faqResults = [];
197        if ($this->configuration->getDb()->numRows($result) > 0) {
198            $faqResults = $this->configuration->getDb()->fetchAll($result) ?? [];
199        }
200
201        // Search custom pages (skip if searching by solution ID)
202        $pageResults = [];
203        if (!is_numeric($searchTerm)) {
204            $pageResults = $this->searchCustomPages($searchTerm, $allLanguages);
205        }
206
207        // Merge FAQ and custom page results
208        return array_merge($faqResults, $pageResults);
209    }
210
211    private function resolveSearchDatabaseType(): string
212    {
213        $driverClass = strtolower($this->getDatabaseDriverClassName($this->configuration->getDb()));
214
215        return match ($driverClass) {
216            'pdomysql' => 'pdo_mysql',
217            'pdopgsql' => 'pdo_pgsql',
218            'pdosqlite' => 'pdo_sqlite',
219            'pdosqlsrv' => 'pdo_sqlsrv',
220            default => $driverClass,
221        };
222    }
223
224    private function getDatabaseDriverClassName(DatabaseDriver $databaseDriver): string
225    {
226        $classNameParts = explode('\\', $databaseDriver::class);
227
228        return end($classNameParts);
229    }
230
231    /**
232     * Search custom pages for the given search term.
233     *
234     * @param string $searchTerm Search term
235     * @param bool $allLanguages Search all languages or current only
236     * @return list<\stdClass> Custom page search results
237     */
238    private function searchCustomPages(string $searchTerm, bool $allLanguages = true): array
239    {
240        $cpTable = Database::getTablePrefix() . 'faqcustompages';
241        $escapedSearchTerm = $this->configuration->getDb()->escape($searchTerm);
242
243        // Build WHERE clause with LIKE for custom pages (no FULLTEXT index)
244        $searchWords = explode(' ', $escapedSearchTerm);
245        $searchConditions = [];
246
247        foreach ($searchWords as $word) {
248            if (strlen($word) <= 2) {
249                continue;
250            }
251
252            // Escape LIKE metacharacters (%, _) to prevent wildcard injection
253            $escapedWord = str_replace(['|', '%', '_'], ['||', '|%', '|_'], $word);
254            $searchConditions[] = sprintf(
255                "(page_title LIKE '%%%s%%' ESCAPE '|' OR content LIKE '%%%s%%' ESCAPE '|')",
256                $escapedWord,
257                $escapedWord,
258            );
259        }
260
261        if ($searchConditions === []) {
262            return [];
263        }
264
265        $searchClause = implode(' OR ', $searchConditions);
266
267        // Build language condition
268        $langCondition = '';
269        if (!$allLanguages) {
270            $langCondition = sprintf(" AND lang = '%s'", $this->configuration->getLanguage()->getLanguage());
271        }
272
273        // Build the query
274        $query = sprintf("
275            SELECT
276                id,
277                lang,
278                0 AS solution_id,
279                0 AS category_id,
280                page_title AS question,
281                content AS answer,
282                slug,
283                0.5 AS score
284            FROM
285                %s
286            WHERE
287                active = 'y'
288                %s
289                AND (%s)
290            ", $cpTable, $langCondition, $searchClause);
291
292        $result = $this->configuration->getDb()->query($query);
293
294        if (!$result || $this->configuration->getDb()->numRows($result) === 0) {
295            return [];
296        }
297
298        $pages = $this->configuration->getDb()->fetchAll($result) ?? [];
299
300        // Mark results as custom pages for later identification
301        foreach ($pages as $page) {
302            $page->content_type = 'page';
303        }
304
305        return $pages;
306    }
307
308    /**
309     * The search function for the Elasticsearch powered full text search.
310     *
311     * @param string $searchTerm Text/Number (solution id)
312     * @param bool   $allLanguages true to search over all languages
313     * @return stdClass[]
314     */
315    public function searchElasticsearch(string $searchTerm, bool $allLanguages = true): array
316    {
317        $elasticsearch = new Elasticsearch($this->configuration);
318
319        $allCategories = $this->getCategory()->getAllCategoryIds();
320        $elasticsearch->setCategoryIds($allCategories);
321
322        $categoryId = $this->getCategoryId();
323        if ($categoryId !== null && 0 < $categoryId) {
324            $children = $this->getCategory()->getChildNodes($categoryId);
325            $elasticsearch->setCategoryIds(array_merge([$categoryId], $children));
326        }
327
328        if (!$allLanguages) {
329            $elasticsearch->setLanguage($this->configuration->getLanguage()->getLanguage());
330        }
331
332        // Elasticsearch search now includes custom pages in the index
333        return $elasticsearch->search($searchTerm);
334    }
335
336    /**
337     * @return stdClass[]
338     */
339    public function searchOpenSearch(string $searchTerm, bool $allLanguages = true): array
340    {
341        $opensearch = new OpenSearch($this->configuration);
342
343        $allCategories = $this->getCategory()->getAllCategoryIds();
344        $opensearch->setCategoryIds($allCategories);
345
346        $categoryId = $this->getCategoryId();
347        if ($categoryId !== null && 0 < $categoryId) {
348            $children = $this->getCategory()->getChildNodes($categoryId);
349            $opensearch->setCategoryIds(array_merge([$categoryId], $children));
350        }
351
352        if (!$allLanguages) {
353            $opensearch->setLanguage($this->configuration->getLanguage()->getLanguage());
354        }
355
356        // OpenSearch search now includes custom pages in the index
357        return $opensearch->search($searchTerm);
358    }
359
360    /**
361     * Logging of search terms for improvements.
362     *
363     * @param string $searchTerm Search term
364     * @throws Exception
365     */
366    public function logSearchTerm(string $searchTerm): void
367    {
368        if (Strings::strlen($searchTerm) === 0) {
369            return;
370        }
371
372        $sanitizedSearchTerm = htmlspecialchars($searchTerm, ENT_QUOTES | ENT_HTML5, encoding: 'UTF-8');
373
374        $dateTime = new DateTime();
375        $query = sprintf(
376            "INSERT INTO %s (id, lang, searchterm, searchdate) VALUES (%d, '%s', '%s', '%s')",
377            $this->table,
378            $this->configuration->getDb()->nextId($this->table, 'id'),
379            $this->configuration->getLanguage()->getLanguage(),
380            $this->configuration->getDb()->escape($sanitizedSearchTerm),
381            $dateTime->format('Y-m-d H:i:s'),
382        );
383
384        $this->configuration->getDb()->query($query);
385    }
386
387    /**
388     * Deletes a search term.
389     */
390    public function deleteSearchTermById(int $searchTermId): bool
391    {
392        $query = sprintf("DELETE FROM %s WHERE id = '%d'", $this->table, $searchTermId);
393
394        return (bool) $this->configuration->getDb()->query($query);
395    }
396
397    /**
398     * Deletes all search terms.
399     */
400    public function deleteAllSearchTerms(): bool
401    {
402        $query = sprintf('DELETE FROM %s', $this->table);
403
404        return (bool) $this->configuration->getDb()->query($query);
405    }
406
407    /**
408     * Returns the most popular searches.
409     *
410     * @param int  $numResults Number of Results, default: 7
411     * @param bool $withLang   Should the language be included in the result?
412     * @param int  $timeWindow Number of days to look back for searches, 0 for all time
413     *
414     * @return array<string[]>
415     */
416    public function getMostPopularSearches(int $numResults = 7, bool $withLang = false, int $timeWindow = 0): array
417    {
418        $searchResult = [];
419
420        $byLang = $withLang ? ', lang' : '';
421        $timeCondition = '';
422        if ($timeWindow > 0) {
423            $dbType = Database::getType();
424            $timeCondition = match ($dbType) {
425                'pgsql', 'pdo_pgsql' => sprintf(" WHERE searchdate >= NOW() - INTERVAL '%d days'", $timeWindow),
426                'sqlite3', 'pdo_sqlite' => sprintf(" WHERE searchdate >= datetime('now', '-%d days')", $timeWindow),
427                'sqlsrv', 'pdo_sqlsrv' => sprintf(' WHERE searchdate >= DATEADD(day, -%d, GETDATE())', $timeWindow),
428                default => sprintf(' WHERE searchdate >= DATE_SUB(NOW(), INTERVAL %d DAY)', $timeWindow),
429            };
430        }
431
432        // Build database-specific LIMIT clause
433        $dbType = Database::getType();
434        $limitClause = match ($dbType) {
435            'sqlsrv', 'pdo_sqlsrv' => sprintf('OFFSET 0 ROWS FETCH NEXT %d ROWS ONLY', $numResults),
436            default => sprintf('LIMIT %d', $numResults),
437        };
438
439        $query = sprintf('
440            SELECT
441                MIN(id) as id, searchterm, COUNT(searchterm) AS number %s
442            FROM
443                %s%s
444            GROUP BY
445                searchterm %s
446            ORDER BY
447                number DESC
448            %s', $byLang, $this->table, $timeCondition, $byLang, $limitClause);
449
450        $result = $this->configuration->getDb()->query($query);
451
452        if (false !== $result) {
453            while (true) {
454                $row = $this->configuration->getDb()->fetchObject($result);
455                if (!$row instanceof \stdClass) {
456                    break;
457                }
458
459                $searchResult[] = array_map(static fn(mixed $value): string => (string) $value, (array) $row);
460            }
461        }
462
463        return $searchResult;
464    }
465
466    /**
467     * Returns row count from the "faqsearches" table.
468     */
469    public function getSearchesCount(): int
470    {
471        $sql = sprintf('SELECT COUNT(*) AS count FROM %s', $this->table);
472
473        $result = $this->configuration->getDb()->query($sql);
474        $row = $this->configuration->getDb()->fetchObject($result);
475
476        return $row instanceof \stdClass ? (int) ($row->count ?? 0) : 0;
477    }
478
479    public function setCategory(Category $category): void
480    {
481        $this->category = $category;
482    }
483
484    public function getCategory(): Category
485    {
486        return $this->category ?? throw new \LogicException('setCategory() must be called before use.');
487    }
488}