Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.51% covered (success)
98.51%
66 / 67
80.00% covered (success)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdoPgsql
98.51% covered (success)
98.51%
66 / 67
80.00% covered (success)
80.00%
4 / 5
20
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 search
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 getMatchingColumnsAsResult
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 getMatchingOrder
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 getMatchingColumns
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * phpMyFAQ PostgreSQL (PDO_PGSQL) 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 2025-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     2025-02-12
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Search\Database;
21
22use Exception;
23use phpMyFAQ\Configuration;
24use phpMyFAQ\Search\SearchDatabase;
25
26/**
27 * Class PdoPgsql
28 *
29 * @package phpMyFAQ\Search\Database
30 */
31class PdoPgsql extends SearchDatabase implements DatabaseInterface
32{
33    /**
34     * List of relevance columns that were actually added to the SELECT clause.
35     *
36     * @var string[]
37     */
38    private array $addedRelevanceColumns = [];
39
40    /**
41     * Constructor.
42     */
43    public function __construct(Configuration $configuration)
44    {
45        parent::__construct($configuration);
46        $this->relevanceSupport = true;
47    }
48
49    /**
50     * Prepares the search and executes it.
51     *
52     * @param  string $searchTerm Search term
53     * @throws Exception
54     */
55    #[\Override]
56    public function search(string $searchTerm): mixed
57    {
58        if (is_numeric($searchTerm) && (bool) $this->configuration->get(item: 'search.searchForSolutionId')) {
59            parent::search($searchTerm);
60
61            return $this->resultSet;
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(haystack: $matchingColumn, needle: '.');
120            $columnName = $qualifiedSuffix === false ? $matchingColumn : substr(string: $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 .= ', ';
157            }
158
159            $order .= $string;
160        }
161
162        return $order;
163    }
164
165    /**
166     * Returns the part of the SQL query with the matching columns.
167     */
168    #[\Override]
169    public function getMatchingColumns(): string
170    {
171        $enableRelevance = $this->configuration->get(item: 'search.enableRelevance');
172
173        if (!$enableRelevance) {
174            return implode(" || ' ' || ", $this->matchingColumns);
175        }
176
177        $matchColumns = [];
178        foreach ($this->matchingColumns as $matchingColumn) {
179            $matchColumns[] = sprintf("to_tsvector(coalesce(%s,''))", $matchingColumn);
180        }
181
182        // Add the ILIKE since the FULLTEXT looks for the exact phrase only
183        $matchColumnsValue = '(' . implode(' || ', $matchColumns) . ') @@ query) OR (';
184        $matchColumnsValue .= implode(" || ' ' || ", $this->matchingColumns);
185
186        return $matchColumnsValue;
187    }
188}