Lines 97.67% 126 / 129
Methods 85.00% 17 / 20
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 2 / 2 100.00% 1 / 1 1
 search 100.00% 24 / 24 100.00% 1 / 1 6
 getMatchingColumnsAsResult 100.00% 21 / 21 100.00% 1 / 1 5
 getMatchingOrder 90.90% 10 / 11 0.00% 0 / 1 5.02
 getMatchingColumns 100.00% 13 / 13 100.00% 1 / 1 6
 [phpMyFAQ\Search\SearchDatabase] getResultColumns 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Search\SearchDatabase] setResultColumns 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Search\SearchDatabase] getTable 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Search\SearchDatabase] setTable 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Search\SearchDatabase] getJoinedTable 100.00% 3 / 3 100.00% 1 / 1 3
 [phpMyFAQ\Search\SearchDatabase] setJoinedTable 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Search\SearchDatabase] getJoinedColumns 100.00% 4 / 4 100.00% 1 / 1 2
 [phpMyFAQ\Search\SearchDatabase] setJoinedColumns 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Search\SearchDatabase] setMatchingColumns 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Search\SearchDatabase] getConditions 88.88% 8 / 9 0.00% 0 / 1 6.05
 [phpMyFAQ\Search\SearchDatabase] buildInClause 75.00% 3 / 4 0.00% 0 / 1 2.06
 [phpMyFAQ\Search\SearchDatabase] setConditions 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Search\SearchDatabase] getMatchClause 100.00% 21 / 21 100.00% 1 / 1 6
 [phpMyFAQ\Search\SearchDatabase] disableRelevance 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Search\SearchDatabase] escapeLikeWildcards 100.00% 2 / 2 100.00% 1 / 1 1
33class Pgsql extends SearchDatabase implements DatabaseInterface
34{
35    /**
36     * List of relevance columns that were actually added to the SELECT clause.
37     *
38     * @var string[]
39     */
40    private array $addedRelevanceColumns = [];
41
42    /**
43     * Constructor.
44     */
45    public function __construct(Configuration $configuration)
46    {
47        parent::__construct($configuration);
48        $this->relevanceSupport = true;
49    }
50
51    /**
52     * Prepares the search and executes it.
53     *
54     * @param  string $searchTerm Search term
55     * @throws Exception
56     */
57    #[Override]
58    public function search(string $searchTerm): mixed
59    {
60        if (is_numeric($searchTerm) && (bool) $this->configuration->get(item: 'search.searchForSolutionId')) {
61            return parent::search($searchTerm);
62        }
63
64        $enableRelevance = $this->configuration->get(item: 'search.enableRelevance');
65
66        $columns = $this->getResultColumns();
67        $columns .= $enableRelevance ? $this->getMatchingColumnsAsResult() : '';
68        $orderBy = $enableRelevance ? 'ORDER BY ' . $this->getMatchingOrder() : '';
69
70        $query = sprintf(
71            "
72                SELECT
73                    %s
74                FROM
75                    %s %s %s %s
76                WHERE
77                    (%s) ILIKE ('%%%s%%') ESCAPE '%s'
78                    %s
79                    %s",
80            $columns,
81            $this->getTable(),
82            $this->getJoinedTable(),
83            $this->getJoinedColumns(),
84            $enableRelevance
85                ? ", plainto_tsquery('" . $this->configuration->getDb()->escape($searchTerm) . "') query "
86                : '',
87            $this->getMatchingColumns(),
88            self::escapeLikeWildcards($this->configuration->getDb()->escape($searchTerm)),
89            self::LIKE_ESCAPE_CHARACTER,
90            $this->getConditions(),
91            $orderBy,
92        );
93
94        $this->resultSet = $this->configuration->getDb()->query($query);
95
96        return $this->resultSet;
97    }
98
99    /**
100     * Add the matching columns into the columns for the resultset.
101     */
102    public function getMatchingColumnsAsResult(): string
103    {
104        $resultColumns = '';
105        $config = $this->configuration->get(item: 'search.relevance');
106        $list = explode(',', (string) $config);
107
108        // Set weight
109        $weights = ['A', 'B', 'C', 'D'];
110        $weight = [];
111        foreach ($list as $columnName) {
112            $weight[$columnName] = array_shift($weights);
113        }
114
115        // Reset the list of added columns
116        $this->addedRelevanceColumns = [];
117
118        foreach ($this->matchingColumns as $matchingColumn) {
119            $qualifiedSuffix = strstr($matchingColumn, needle: '.');
120            $columnName = $qualifiedSuffix === false ? $matchingColumn : substr($qualifiedSuffix, offset: 1);
121
122            if (array_key_exists($columnName, $weight)) {
123                $column = sprintf(
124                    "TS_RANK_CD(SETWEIGHT(TO_TSVECTOR(COALESCE(%s, '')), '%s'), query) AS relevance_%s",
125                    $matchingColumn,
126                    $weight[$columnName],
127                    $columnName,
128                );
129
130                $resultColumns .= ', ' . $column;
131                $this->addedRelevanceColumns[] = $columnName;
132            }
133        }
134
135        return $resultColumns;
136    }
137
138    /**
139     * Returns the part of the SQL query with the order by.
140     *
141     * Weight calculates the order depend on the search.relevance order
142     */
143    public function getMatchingOrder(): string
144    {
145        $list = explode(',', (string) $this->configuration->get(item: 'search.relevance'));
146        $order = '';
147
148        foreach ($list as $field) {
149            // Only add to ORDER BY if this relevance column was actually added to SELECT
150            if (!in_array($field, $this->addedRelevanceColumns, strict: true)) {
151                continue;
152            }
153
154            $string = sprintf('relevance_%s DESC', $field);
155            if ($order === '' || $order === '0') {
156                $order .= $string;
157                continue;
158            }
159
160            $order .= ', ' . $string;
161        }
162
163        return $order;
164    }
165
166    /**
167     * Returns the part of the SQL query with the matching columns.
168     */
169    #[Override]
170    public function getMatchingColumns(): string
171    {
172        $enableRelevance = (bool) $this->configuration->get(item: 'search.enableRelevance');
173        $matchColumns = '';
174
175        if ($enableRelevance) {
176            foreach ($this->matchingColumns as $matchingColumn) {
177                $match = sprintf("to_tsvector(coalesce(%s,''))", $matchingColumn);
178                if ($matchColumns === '' || $matchColumns === '0') {
179                    $matchColumns .= '(' . $match;
180                    continue;
181                }
182
183                $matchColumns .= ' || ' . $match;
184            }
185
186            // Add the ILIKE since the FULLTEXT looks for the exact phrase only
187            $matchColumns .= ') @@ query) OR (' . implode(" || ' ' || ", $this->matchingColumns);
188        }
189
190        if (!$enableRelevance) {
191            $matchColumns = implode(" || ' ' || ", $this->matchingColumns);
192        }
193
194        return $matchColumns;
195    }
196}

Inherited from phpMyFAQ\Search\SearchDatabase

112    public function getResultColumns(): string
113    {
114        return implode(', ', $this->resultColumns);
115    }
122    public function setResultColumns(array $columns): SearchDatabase
123    {
124        $this->resultColumns = $columns;
125
126        return $this;
127    }
132    public function getTable(): string
133    {
134        return $this->table;
135    }
142    public function setTable(string $table): SearchDatabase
143    {
144        $this->table = $table;
145
146        return $this;
147    }
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    }
166    public function setJoinedTable(string $joinedTable = ''): SearchDatabase
167    {
168        $this->joinedTable = $joinedTable;
169
170        return $this;
171    }
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    }
192    public function setJoinedColumns(array $joinedColumns): SearchDatabase
193    {
194        $this->joinedColumns = $joinedColumns;
195
196        return $this;
197    }
212    public function setMatchingColumns(array $matchingColumns): SearchDatabase
213    {
214        $this->matchingColumns = $matchingColumns;
215
216        return $this;
217    }
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    }
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    }
260    public function setConditions(array $conditions): SearchDatabase
261    {
262        $this->conditions = $conditions;
263
264        return $this;
265    }
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    }
310    public function disableRelevance(): void
311    {
312        $this->relevanceSupport = false;
313    }
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    }