Lines 97.22% 70 / 72
Methods 88.88% 16 / 18
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 search 100.00% 12 / 12 100.00% 1 / 1 1
 getResultColumns 100.00% 1 / 1 100.00% 1 / 1 1
 setResultColumns 100.00% 2 / 2 100.00% 1 / 1 1
 getTable 100.00% 1 / 1 100.00% 1 / 1 1
 setTable 100.00% 2 / 2 100.00% 1 / 1 1
 getJoinedTable 100.00% 3 / 3 100.00% 1 / 1 3
 setJoinedTable 100.00% 2 / 2 100.00% 1 / 1 1
 getJoinedColumns 100.00% 4 / 4 100.00% 1 / 1 2
 setJoinedColumns 100.00% 2 / 2 100.00% 1 / 1 1
 getMatchingColumns 100.00% 1 / 1 100.00% 1 / 1 1
 setMatchingColumns 100.00% 2 / 2 100.00% 1 / 1 1
 getConditions 88.88% 8 / 9 0.00% 0 / 1 6.05
 buildInClause 75.00% 3 / 4 0.00% 0 / 1 2.06
 setConditions 100.00% 2 / 2 100.00% 1 / 1 1
 getMatchClause 100.00% 21 / 21 100.00% 1 / 1 6
 disableRelevance 100.00% 1 / 1 100.00% 1 / 1 1
 escapeLikeWildcards 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Search\AbstractSearch] __construct 100.00% 1 / 1 100.00% 1 / 1 1
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}

Inherited from phpMyFAQ\Search\AbstractSearch

41    public function __construct(
42        protected Configuration $configuration,
43    ) {
44    }