Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.35% covered (success)
82.35%
98 / 119
68.00% covered (warning)
68.00%
17 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdoPgsql
82.35% covered (success)
82.35%
98 / 119
68.00% covered (warning)
68.00%
17 / 25
56.13
0.00% covered (danger)
0.00%
0 / 1
 pdo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 connect
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
2.15
 error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 escape
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchArray
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 fetchRow
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 fetchAll
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fetchObject
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 numRows
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 log
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTableStatus
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTableNames
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
1
 getOne
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 nextId
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 query
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 queryPrepared
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 affectedRows
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepare
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clientVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 serverVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 close
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __destruct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lastInsertId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 now
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The phpMyFAQ\Database\PdoPgsql class provides methods and functions for PostgreSQL with PDO.
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-09
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Database;
21
22use PDO;
23use PDOException;
24use PDOStatement;
25use phpMyFAQ\Core\Exception;
26use SensitiveParameter;
27
28/**
29 * Class PdoPgsql
30 *
31 * @package phpMyFAQ\Database
32 */
33class PdoPgsql implements DatabaseDriver
34{
35    /**
36     * @var string[] Tables.
37     */
38    public array $tableNames = [];
39
40    /**
41     * The connection object.
42     */
43    private ?PDO $pdo = null;
44
45    /**
46     * Returns the active connection or fails loudly when connect() has not
47     * been called yet or the connection was already closed.
48     *
49     * @throws Exception
50     */
51    private function pdo(): PDO
52    {
53        return $this->pdo ?? throw new Exception(message: 'There is no open database connection.');
54    }
55
56    /**
57     * The query log string.
58     */
59    private string $sqlLog = '';
60
61    /**
62     * The last query result for tracking affected rows.
63     */
64    private ?PDOStatement $lastStatement = null;
65
66    /**
67     * Connects to the database.
68     *
69     * @param string $host Hostname or path to socket
70     * @param string $user Username
71     * @param string $password Password
72     * @param string $database Database name
73     * @return null|bool true, if connected, otherwise false
74     * @throws Exception
75     */
76    public function connect(
77        string $host,
78        #[SensitiveParameter]
79        string $user,
80        #[SensitiveParameter]
81        string $password,
82        string $database = '',
83        ?int $port = null,
84    ): ?bool {
85        $dsn = sprintf('pgsql:host=%s;dbname=%s;port=%s', $host, $database, $port);
86        try {
87            $this->pdo = new PDO($dsn, $user, $password);
88            $this->pdo()->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
89        } catch (PDOException $pdoException) {
90            throw new Exception($pdoException->getMessage());
91        }
92
93        return true;
94    }
95
96    /**
97     * Returns the error string.
98     */
99    public function error(): string
100    {
101        return $this->pdo?->errorInfo()[2] ?? '';
102    }
103
104    /**
105     * Escapes a string for use in a query.
106     *
107     * Note: Unlike PDO::quote(), this method intentionally does NOT add surrounding quotes.
108     * It only escapes special characters so callers can uniformly wrap values in SQL strings.
109     */
110    public function escape(string $string): string
111    {
112        // For PostgreSQL, escape single quotes by doubling them (SQL standard)
113        return str_replace(search: "'", replace: "''", subject: $string);
114    }
115
116    /**
117     * Fetch a result row as an associative array.
118     */
119    public function fetchArray(mixed $result): ?array
120    {
121        if (!$result instanceof PDOStatement) {
122            return null;
123        }
124
125        $row = $result->fetch(PDO::FETCH_ASSOC);
126
127        return is_array($row) ? $row : null;
128    }
129
130    /**
131     * Fetch a result row.
132     */
133    public function fetchRow(mixed $result): mixed
134    {
135        if (!$result instanceof PDOStatement) {
136            return false;
137        }
138
139        $row = $result->fetch(PDO::FETCH_NUM);
140
141        return is_array($row) ? $row[0] ?? false : false;
142    }
143
144    /**
145     * Fetches a complete result as an object.
146     *
147     * @param mixed $result Result set
148     * @throws Exception
149     * @return list<\stdClass>|null
150     */
151    public function fetchAll(mixed $result): ?array
152    {
153        if (!$result instanceof PDOStatement) {
154            throw new Exception('Error while fetching result: ' . $this->error());
155        }
156
157        /* @mago-expect lint:inline-variable-return - the variable carries the @var type for mago analyze */
158        /** @var list<\stdClass> $rows */
159        $rows = $result->fetchAll(PDO::FETCH_OBJ);
160
161        return $rows;
162    }
163
164    /**
165     * Fetch a result row as an object.
166     * This function fetches a result row as an object.
167     *
168     * @return \stdClass|false|null
169     *
170     * @throws Exception
171     */
172    public function fetchObject(mixed $result): mixed
173    {
174        if (!$result instanceof PDOStatement) {
175            return false;
176        }
177
178        /* @mago-expect lint:inline-variable-return - the variable carries the @var type for mago analyze */
179        /** @var \stdClass|false|null $row */
180        $row = $result->fetch(PDO::FETCH_OBJ);
181
182        return $row;
183    }
184
185    /**
186     * Number of rows in a result.
187     */
188    public function numRows(mixed $result): int
189    {
190        return $result instanceof PDOStatement ? $result->rowCount() : 0;
191    }
192
193    /**
194     * Logs the queries.
195     */
196    public function log(): string
197    {
198        return $this->sqlLog;
199    }
200
201    /**
202     * This function returns the table status.
203     *
204     * @param string $prefix Table prefix
205     * @return string[]
206     */
207    public function getTableStatus(string $prefix = ''): array
208    {
209        $status = [];
210        foreach ($this->getTableNames($prefix) as $table) {
211            $status[$table] = $this->getOne('SELECT count(*) FROM ' . $table);
212        }
213
214        return $status;
215    }
216
217    /**
218     * Returns an array with all table names.
219     *
220     * @todo Have to be refactored because of https://github.com/thorsten/phpMyFAQ/issues/965
221     *
222     * @param string $prefix Table prefix
223     *
224     * @return string[]
225     */
226    public function getTableNames(string $prefix = ''): array
227    {
228        return $this->tableNames = [
229            $prefix . 'faqadminlog',
230            $prefix . 'faqattachment',
231            $prefix . 'faqattachment_file',
232            $prefix . 'faqbackup',
233            $prefix . 'faqbookmarks',
234            $prefix . 'faqcaptcha',
235            $prefix . 'faqcategories',
236            $prefix . 'faqcategoryrelations',
237            $prefix . 'faqcategory_group',
238            $prefix . 'faqcategory_news',
239            $prefix . 'faqcategory_order',
240            $prefix . 'faqcategory_user',
241            $prefix . 'faqchanges',
242            $prefix . 'faqchat_messages',
243            $prefix . 'faqcomments',
244            $prefix . 'faqconfig',
245            $prefix . 'faqcustompages',
246            $prefix . 'faqdata',
247            $prefix . 'faqdata_group',
248            $prefix . 'faqdata_revisions',
249            $prefix . 'faqdata_tags',
250            $prefix . 'faqdata_user',
251            $prefix . 'faqforms',
252            $prefix . 'faqglossary',
253            $prefix . 'faqgroup',
254            $prefix . 'faqgroup_right',
255            $prefix . 'faqinstances',
256            $prefix . 'faqinstances_config',
257            $prefix . 'faqnews',
258            $prefix . 'faqquestions',
259            $prefix . 'faqright',
260            $prefix . 'faqsearches',
261            $prefix . 'faqseo',
262            $prefix . 'faqsessions',
263            $prefix . 'faqstopwords',
264            $prefix . 'faqtags',
265            $prefix . 'faquser',
266            $prefix . 'faquserdata',
267            $prefix . 'faquserlogin',
268            $prefix . 'faquser_group',
269            $prefix . 'faquser_right',
270            $prefix . 'faqvisits',
271            $prefix . 'faqvoting',
272        ];
273    }
274
275    /**
276     * Returns just one row.
277     */
278    /**
279     * @throws Exception
280     */
281    private function getOne(string $query): string
282    {
283        $statement = $this->pdo()->prepare($query);
284        if ($statement === false) {
285            throw new Exception('Cannot prepare query: ' . $query);
286        }
287
288        $statement->execute();
289
290        $row = $statement->fetch(PDO::FETCH_NUM);
291
292        return is_array($row) ? (string) ($row[0] ?? '') : '';
293    }
294
295    /**
296     * This function is a replacement for MySQL's auto-increment so that
297     * we don't need it anymore.
298     *
299     * @param string $table The name of the table
300     * @param string $column The name of the ID column
301     * @throws Exception
302     */
303    public function nextId(string $table, string $column): int
304    {
305        $query = sprintf('SELECT MAX(%s) AS current_id FROM  %s', $column, $table);
306
307        $statement = $this->pdo()->prepare($query);
308        if ($statement === false) {
309            throw new Exception('Cannot prepare query: ' . $query);
310        }
311
312        $statement->execute();
313
314        $current = $statement->fetch(PDO::FETCH_NUM);
315
316        return is_array($current) ? (int) ($current[0] ?? 0) + 1 : 1;
317    }
318
319    /**
320     * This function sends a query to the database.
321     *
322     * @return PDOStatement|false $result
323     * @throws Exception
324     */
325    public function query(string $query, int $offset = 0, int $rowcount = 0): mixed
326    {
327        $this->sqlLog .= $query;
328
329        if (0 < $rowcount) {
330            $query .= sprintf(' LIMIT %d OFFSET %d', $rowcount, $offset);
331        }
332
333        try {
334            $result = $this->pdo()->query($query);
335        } catch (PDOException $pdoException) {
336            throw new Exception($pdoException->getMessage());
337        }
338
339        if (false === $result) {
340            $this->sqlLog .= (string) $this->pdo()->errorCode() . ': ' . $this->error();
341        }
342
343        $this->lastStatement = $result instanceof PDOStatement ? $result : null;
344
345        return $result;
346    }
347
348    /**
349     * Sends a parameterized query; `?` placeholders are bound by PDO.
350     *
351     * @param array<int, string|int|float|null> $params
352     * @throws Exception
353     */
354    public function queryPrepared(string $query, array $params): PDOStatement
355    {
356        $this->sqlLog .= $query;
357
358        if (!$this->pdo instanceof PDO) {
359            throw new Exception('No database connection available for query: ' . $query);
360        }
361
362        try {
363            $statement = $this->pdo()->prepare($query);
364            if ($statement === false) {
365                throw new Exception('Cannot prepare query: ' . $query);
366            }
367
368            $statement->execute($params);
369        } catch (PDOException $pdoException) {
370            throw new Exception($pdoException->getMessage() . ' in query: ' . $query);
371        }
372
373        $this->lastStatement = $statement;
374
375        return $statement;
376    }
377
378    /**
379     * Returns the number of rows affected by the last INSERT, UPDATE, or DELETE query.
380     */
381    public function affectedRows(): int
382    {
383        return $this->lastStatement?->rowCount() ?? 0;
384    }
385
386    /**
387     * Prepares a statement for execution and returns a statement object.
388     *
389     * @param string $query The SQL query
390     * @param array $options The driver options
391     */
392    public function prepare(string $query, array $options = []): PDOStatement|false
393    {
394        return $this->pdo()->prepare($query, $options);
395    }
396
397    /**
398     * Executes a prepared statement.
399     *
400     * @param PDOStatement $pdoStatement The prepared statement
401     * @param array $params The parameters
402     */
403    public function execute(PDOStatement $pdoStatement, array $params = []): bool
404    {
405        return $pdoStatement->execute($params);
406    }
407
408    /**
409     * Returns the client version string.
410     */
411    public function clientVersion(): string
412    {
413        return (string) $this->pdo()->getAttribute(PDO::ATTR_CLIENT_VERSION);
414    }
415
416    /**
417     * Returns the server version string.
418     */
419    public function serverVersion(): string
420    {
421        return (string) $this->pdo()->getAttribute(PDO::ATTR_SERVER_VERSION);
422    }
423
424    /**
425     * Closes the connection to the database.
426     */
427    public function close(): void
428    {
429        $this->pdo = null;
430    }
431
432    public function __destruct()
433    {
434        $this->close();
435    }
436
437    /**
438     * Returns the ID of the last inserted row.
439     */
440    public function lastInsertId(): int|string
441    {
442        return (int) $this->pdo()->lastInsertId();
443    }
444
445    public function now(): string
446    {
447        return 'NOW()';
448    }
449}