Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.18% covered (success)
97.18%
69 / 71
88.24% covered (success)
88.24%
15 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchDatabase
97.18% covered (success)
97.18%
69 / 71
88.24% covered (success)
88.24%
15 / 17
31
0.00% covered (danger)
0.00%
0 / 1
 search
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getResultColumns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setResultColumns
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getJoinedTable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 setJoinedTable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getJoinedColumns
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setJoinedColumns
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMatchingColumns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMatchingColumns
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getConditions
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 buildInClause
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 setConditions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMatchClause
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 disableRelevance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 escapeLikeWildcards
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * phpMyFAQ database-based search classes.
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 * @copyright 2010-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2010-06-06
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Search;
21
22use phpMyFAQ\Strings;
23
24/**
25 * Class SearchDatabase
26 *
27 * @package phpMyFAQ\Search
28 */
29class SearchDatabase extends AbstractSearch implements SearchInterface
30{
31    /**
32     * LIKE/ILIKE escape character. Every ESCAPE clause built by this class or
33     * its driver subclasses must use this character, otherwise the wildcard
34     * escaping in escapeLikeWildcards() is silently disabled.
35     */
36    protected const string LIKE_ESCAPE_CHARACTER = '|';
37
38    /**
39     * Searching database table.
40     */
41    protected string $table = '';
42
43    /**
44     * Joined searching database table.
45     */
46    protected string $joinedTable = '';
47
48    /**
49     * Columns for the result set.
50     *
51     * @var string[]
52     */
53    protected array $resultColumns = [];
54
55    /**
56     * Columns for the joined table.
57     *
58     * @var string[]
59     */
60    protected array $joinedColumns = [];
61
62    /**
63     * Matching columns for the search.
64     *
65     * @var string[]
66     */
67    protected array $matchingColumns = [];
68
69    /**
70     * Conditions columns with their values.
71     *
72     * @var array<string, array<int>|int|string>
73     */
74    protected array $conditions = [];
75
76    /**
77     * Flag if a database supports search relevance.
78     */
79    protected bool $relevanceSupport = false;
80
81    /**
82     * Prepares the search and executes it.
83     *
84     * @param string $searchTerm Search term
85     */
86    public function search(string $searchTerm): mixed
87    {
88        $query = sprintf(
89            '
90            SELECT
91                %s
92            FROM 
93                %s %s %s
94            WHERE
95                %s = %s',
96            $this->getResultColumns(),
97            $this->getTable(),
98            $this->getJoinedTable(),
99            $this->getJoinedColumns(),
100            $this->getMatchingColumns(),
101            $searchTerm,
102        );
103
104        $this->resultSet = $this->configuration->getDb()->query($query);
105
106        return $this->resultSet;
107    }
108
109    /**
110     * Returns the part of the SQL query with the columns for the result set.
111     */
112    public function getResultColumns(): string
113    {
114        return implode(', ', $this->resultColumns);
115    }
116
117    /**
118     * Sets the part of the SQL query with the columns for the result set.
119     *
120     * @param string[] $columns Array of columns
121     */
122    public function setResultColumns(array $columns): SearchDatabase
123    {
124        $this->resultColumns = $columns;
125
126        return $this;
127    }
128
129    /**
130     * Returns the search table.
131     */
132    public function getTable(): string
133    {
134        return $this->table;
135    }
136
137    /**
138     * Sets search table.
139     *
140     * @param string $table Table where search should be performed
141     */
142    public function setTable(string $table): SearchDatabase
143    {
144        $this->table = $table;
145
146        return $this;
147    }
148
149    /**
150     * Returns the joined table.
151     */
152    public function getJoinedTable(): string
153    {
154        if ($this->joinedTable === '' || $this->joinedTable === '0') {
155            return '';
156        }
157
158        return ' LEFT JOIN ' . $this->joinedTable . ' ON ';
159    }
160
161    /**
162     * Sets joined search table.
163     *
164     * @param string $joinedTable Joined table where search should be performed
165     */
166    public function setJoinedTable(string $joinedTable = ''): SearchDatabase
167    {
168        $this->joinedTable = $joinedTable;
169
170        return $this;
171    }
172
173    /**
174     * Returns the part of the SQL query with the columns for the join.
175     */
176    public function getJoinedColumns(): string
177    {
178        $joinedColumns = '';
179
180        foreach ($this->joinedColumns as $joinedColumn) {
181            $joinedColumns .= $joinedColumn . ' AND ';
182        }
183
184        return Strings::substr($joinedColumns, 0, -4);
185    }
186
187    /**
188     * Sets the part of the SQL query with the columns for the join.
189     *
190     * @param string[] $joinedColumns Array of columns
191     */
192    public function setJoinedColumns(array $joinedColumns): SearchDatabase
193    {
194        $this->joinedColumns = $joinedColumns;
195
196        return $this;
197    }
198
199    /**
200     * Returns the part of the SQL query with the matching columns.
201     */
202    public function getMatchingColumns(): string
203    {
204        return implode(', ', $this->matchingColumns);
205    }
206
207    /**
208     * Sets the part of the SQL query with the matching columns.
209     *
210     * @param string[] $matchingColumns Array of columns
211     */
212    public function setMatchingColumns(array $matchingColumns): SearchDatabase
213    {
214        $this->matchingColumns = $matchingColumns;
215
216        return $this;
217    }
218
219    /**
220     * Returns the part of the SQL query with the conditions.
221     */
222    public function getConditions(): string
223    {
224        $conditions = '';
225        $db = $this->configuration->getDb();
226
227        foreach ($this->conditions as $column => $value) {
228            if (!preg_match('/^[A-Za-z_][A-Za-z0-9_.]*$/', $column)) {
229                continue;
230            }
231
232            $conditions .= match (true) {
233                is_array($value) => $this->buildInClause($column, $value),
234                is_int($value) => ' AND ' . $column . ' = ' . $value,
235                default => ' AND ' . $column . " = '" . $db->escape((string) $value) . "'",
236            };
237        }
238
239        return $conditions;
240    }
241
242    /**
243     * @param array<int|string> $value
244     */
245    private function buildInClause(string $column, array $value): string
246    {
247        $ids = array_map(static fn($v): int => (int) $v, $value);
248        if ($ids === []) {
249            return '';
250        }
251
252        return ' AND ' . $column . ' IN (' . implode(', ', $ids) . ')';
253    }
254
255    /**
256     * Sets the part of the SQL query with the conditions.
257     *
258     * @param array<string, array<int>|int|string> $conditions Array of columns
259     */
260    public function setConditions(array $conditions): SearchDatabase
261    {
262        $this->conditions = $conditions;
263
264        return $this;
265    }
266
267    /**
268     * Creates the part for the WHERE clause.
269     *
270     * @param string $searchTerm Search term
271     */
272    public function getMatchClause(string $searchTerm = ''): string
273    {
274        $splitTerms = Strings::preg_split("/\s+/", $searchTerm);
275        $keys = is_array($splitTerms) ? $splitTerms : [];
276        $numKeys = count($keys);
277        $numMatch = count($this->matchingColumns);
278        $where = '';
279
280        for ($i = 0; $i < $numKeys; ++$i) {
281            if ($where !== '') {
282                $where .= ' OR';
283            }
284
285            $where .= ' (';
286            for ($j = 0; $j < $numMatch; ++$j) {
287                if ($j !== 0) {
288                    $where .= ' OR ';
289                }
290
291                $where = sprintf(
292                    "%s%s LIKE '%%%s%%' ESCAPE '%s'",
293                    $where,
294                    $this->matchingColumns[$j],
295                    self::escapeLikeWildcards($this->configuration->getDb()->escape((string) $keys[$i])),
296                    self::LIKE_ESCAPE_CHARACTER,
297                );
298            }
299
300            $where .= ')';
301        }
302
303        return $where;
304    }
305
306    /**
307     * Disables relevance support if we don't need it even if the database
308     * supports it.
309     */
310    public function disableRelevance(): void
311    {
312        $this->relevanceSupport = false;
313    }
314
315    /**
316     * Escapes LIKE wildcard metacharacters (%, _) in a search term
317     * to prevent LIKE wildcard injection.
318     */
319    protected static function escapeLikeWildcards(string $term): string
320    {
321        $escape = self::LIKE_ESCAPE_CHARACTER;
322
323        return str_replace([$escape, '%', '_'], [$escape . $escape, $escape . '%', $escape . '_'], $term);
324    }
325}