Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.01% covered (warning)
78.01%
110 / 141
77.27% covered (warning)
77.27%
17 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Pgsql
78.01% covered (warning)
78.01%
110 / 141
77.27% covered (warning)
77.27%
17 / 22
66.52
0.00% covered (danger)
0.00%
0 / 1
 connect
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 query
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 queryPrepared
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 numberPlaceholders
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 affectedRows
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 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
 fetchAll
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 fetchObject
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fetchRow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 numRows
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 fetchArray
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getOne
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 nextId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clientVersion
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 serverVersion
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTableNames
100.00% covered (success)
100.00%
45 / 45
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
 lastInsertId
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 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\Pgsql class provides methods and functions for a PostgreSQL
5 * database.
6 *
7 * This Source Code Form is subject to the terms of the Mozilla Public License,
8 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
9 * obtain one at https://mozilla.org/MPL/2.0/.
10 *
11 * @package   phpMyFAQ
12 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
13 * @author    Tom Rochester <tom.rochester@gmail.com>
14 * @copyright 2005-2026 phpMyFAQ Team
15 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2005-12-13
18 */
19
20declare(strict_types=1);
21
22namespace phpMyFAQ\Database;
23
24use Exception;
25use PgSql\Connection;
26use PgSql\Result;
27use phpMyFAQ\Database;
28use SensitiveParameter;
29
30/**
31 * Class Pgsql
32 *
33 * @package phpMyFAQ\Database
34 * @deprecated Use PDO instead. Will be removed in the v5.0 release.
35 */
36class Pgsql implements DatabaseDriver
37{
38    /**
39     * The query log string.
40     */
41    private string $sqlLog = '';
42
43    /**
44     * Tables.
45     *
46     * @var string[]
47     */
48    public array $tableNames = [];
49
50    /**
51     * The connection resource.
52     */
53    private Connection|bool $conn = false;
54
55    /**
56     * The last query result for tracking affected rows.
57     */
58    private Result|bool|null $lastResult = null;
59
60    /**
61     * Connects to the database.
62     *
63     * @param string $host Database hostname
64     * @param string $user Database username
65     * @param string $password Password
66     * @param string $database Database name
67     * @return null|bool true, if connected, otherwise false
68     */
69    public function connect(
70        string $host,
71        #[SensitiveParameter]
72        string $user,
73        #[SensitiveParameter]
74        string $password,
75        string $database = '',
76        ?int $port = null,
77    ): ?bool {
78        $connectionString = sprintf(
79            'host=%s port=%d dbname=%s user=%s password=%s',
80            $host,
81            $port,
82            $database,
83            $user,
84            $password,
85        );
86
87        try {
88            $this->conn = pg_connect($connectionString);
89
90            if ($this->conn === false) {
91                throw new Exception('No PostgreSQL connection opened yet');
92            }
93
94            if ($database === '') {
95                throw new Exception('Database name is empty');
96            }
97        } catch (Exception $exception) {
98            Database::errorPage($exception->getMessage());
99            die();
100        }
101
102        return true;
103    }
104
105    /**
106     * This function sends a query to the database.
107     *
108     * @return bool|Result $result
109     */
110    public function query(string $query, int $offset = 0, int $rowcount = 0): bool|Result
111    {
112        $this->sqlLog .= $query;
113
114        if (0 < $rowcount) {
115            $query .= sprintf(' LIMIT %d OFFSET %d', $rowcount, $offset);
116        }
117
118        /* @mago-expect analysis:possibly-invalid-argument - the pg_* functions are shadowed in tests; the stubbed connection must pass through */
119        $result = pg_query($this->conn, $query);
120
121        if (!$result) {
122            $this->sqlLog .= $this->error();
123            return false;
124        }
125
126        $this->lastResult = $result;
127
128        if (pg_result_status($result) === PGSQL_COMMAND_OK) {
129            return true;
130        }
131
132        return $result;
133    }
134
135    /**
136     * Sends a parameterized query; `?` placeholders are converted to the
137     * PostgreSQL `$n` form and bound via pg_query_params().
138     *
139     * @param array<int, string|int|float|null> $params
140     */
141    public function queryPrepared(string $query, array $params): bool|Result
142    {
143        $this->sqlLog .= $query;
144
145        if (!$this->conn instanceof Connection) {
146            return false;
147        }
148
149        $result = pg_query_params($this->conn, self::numberPlaceholders($query), $params);
150
151        if (!$result) {
152            $this->sqlLog .= $this->error();
153            return false;
154        }
155
156        $this->lastResult = $result;
157
158        return $result;
159    }
160
161    /**
162     * Rewrites positional `?` placeholders to PostgreSQL's `$1..$n`,
163     * leaving question marks inside single-quoted literals untouched.
164     */
165    public static function numberPlaceholders(string $query): string
166    {
167        $converted = '';
168        $position = 0;
169        $inLiteral = false;
170
171        foreach (str_split($query) as $character) {
172            if ($character === "'") {
173                $inLiteral = !$inLiteral;
174            }
175
176            if ($character === '?' && !$inLiteral) {
177                ++$position;
178                $converted .= '$' . $position;
179                continue;
180            }
181
182            $converted .= $character;
183        }
184
185        return $converted;
186    }
187
188    /**
189     * Returns the number of rows affected by the last INSERT, UPDATE, or DELETE query.
190     */
191    public function affectedRows(): int
192    {
193        if ($this->lastResult instanceof Result) {
194            return pg_affected_rows($this->lastResult);
195        }
196
197        return 0;
198    }
199
200    /**
201     * Returns the error string.
202     */
203    public function error(): string
204    {
205        /* @mago-expect analysis:possibly-invalid-argument - the pg_* functions are shadowed in tests; the stubbed connection must pass through */
206        return pg_last_error($this->conn);
207    }
208
209    /**
210     * Escapes a string for use in a query.
211     */
212    public function escape(string $string): string
213    {
214        /* @mago-expect analysis:possibly-invalid-argument - the pg_* functions are shadowed in tests; the stubbed connection must pass through */
215        return pg_escape_string($this->conn, $string);
216    }
217
218    /**
219     * Fetches a complete result as an object.
220     *
221     * @param mixed $result Resultset
222     * @throws Exception
223     * @return list<\stdClass>|null
224     */
225    public function fetchAll(mixed $result): ?array
226    {
227        $ret = [];
228        if (false === $result) {
229            throw new Exception('Error while fetching result: ' . $this->error());
230        }
231
232        while (true) {
233            $row = $this->fetchObject($result);
234            if (!is_object($row)) {
235                break;
236            }
237
238            $ret[] = $row;
239        }
240
241        return $ret;
242    }
243
244    /**
245     * Fetch a result row as an object.
246     *
247     * @return \stdClass|false|null
248     */
249    public function fetchObject(mixed $result): mixed
250    {
251        /* @mago-expect lint:inline-variable-return - the variable carries the @var type for mago analyze */
252        /* @mago-expect analysis:mixed-argument - the pg_* functions are shadowed in tests; the stubbed result must pass through */
253        /** @var \stdClass|false|null $row */
254        $row = pg_fetch_object($result);
255
256        return $row;
257    }
258
259    /**
260     * Fetch a result row.
261     */
262    public function fetchRow(mixed $result): array|false
263    {
264        /* @mago-expect analysis:mixed-argument - the pg_* functions are shadowed in tests; the stubbed result must pass through */
265        return pg_fetch_row($result);
266    }
267
268    /**
269     * Number of rows in a result.
270     */
271    public function numRows(mixed $result): int
272    {
273        /* @mago-expect analysis:mixed-argument - the pg_* functions are shadowed in tests; the stubbed result must pass through */
274        return pg_num_rows($result);
275    }
276
277    /**
278     * Logs the queries.
279     */
280    public function log(): string
281    {
282        return $this->sqlLog;
283    }
284
285    /**
286     * This function returns the table status.
287     *
288     * @param string $prefix Table prefix
289     */
290    public function getTableStatus(string $prefix = ''): array
291    {
292        $select = 'SELECT relname FROM pg_stat_user_tables ORDER BY relname;';
293        $arr = [];
294        $result = $this->query($select);
295        while (true) {
296            $row = $this->fetchArray($result);
297            if (!is_array($row) || !array_key_exists('relname', $row)) {
298                break;
299            }
300
301            $tableName = (string) $row['relname'];
302            $arr[$tableName] = $this->getOne('SELECT count(1) FROM ' . $tableName . ';');
303        }
304
305        return $arr;
306    }
307
308    /**
309     * Fetch a result row as an object.
310     */
311    public function fetchArray(mixed $result): ?array
312    {
313        /* @mago-expect analysis:mixed-argument - the pg_* functions are shadowed in tests; the stubbed result must pass through */
314        $row = pg_fetch_array($result, row: null, mode: PGSQL_ASSOC);
315
316        return is_array($row) ? $row : [];
317    }
318
319    /**
320     * Returns just one row.
321     */
322    private function getOne(string $query): string
323    {
324        /* @mago-expect analysis:possibly-invalid-argument - the pg_* functions are shadowed in tests; the stubbed result must pass through */
325        $row = pg_fetch_row($this->query($query));
326
327        return is_array($row) ? (string) ($row[0] ?? '') : '';
328    }
329
330    /**
331     * Returns the next ID of a table.
332     *
333     * @param string $table the name of the table
334     * @param string $column    the name of the ID column
335     */
336    public function nextId(string $table, string $column): int
337    {
338        return (int) $this->getOne("SELECT nextval('" . $table . '_' . $column . "_seq') as current_id;");
339    }
340
341    /**
342     * This function returns the client version string.
343     */
344    public function clientVersion(): string
345    {
346        /* @mago-expect analysis:possibly-invalid-argument - the pg_* functions are shadowed in tests; the stubbed connection must pass through */
347        $pgVersion = pg_version($this->conn);
348
349        return (string) ($pgVersion['client'] ?? 'n/a');
350    }
351
352    /**
353     * Returns the server version string.
354     */
355    public function serverVersion(): string
356    {
357        /* @mago-expect analysis:possibly-invalid-argument - the pg_* functions are shadowed in tests; the stubbed connection must pass through */
358        $pgVersion = pg_version($this->conn);
359
360        return (string) ($pgVersion['server'] ?? 'n/a');
361    }
362
363    /**
364     * Returns an array with all table names.
365     *
366     * @todo Have to be refactored because of https://github.com/thorsten/phpMyFAQ/issues/965
367     *
368     * @param string $prefix Table prefix
369     *
370     * @return string[]
371     */
372    public function getTableNames(string $prefix = ''): array
373    {
374        return $this->tableNames = [
375            $prefix . 'faqadminlog',
376            $prefix . 'faqattachment',
377            $prefix . 'faqattachment_file',
378            $prefix . 'faqbackup',
379            $prefix . 'faqbookmarks',
380            $prefix . 'faqcaptcha',
381            $prefix . 'faqcategories',
382            $prefix . 'faqcategoryrelations',
383            $prefix . 'faqcategory_group',
384            $prefix . 'faqcategory_news',
385            $prefix . 'faqcategory_order',
386            $prefix . 'faqcategory_user',
387            $prefix . 'faqchanges',
388            $prefix . 'faqchat_messages',
389            $prefix . 'faqcomments',
390            $prefix . 'faqconfig',
391            $prefix . 'faqcustompages',
392            $prefix . 'faqdata',
393            $prefix . 'faqdata_group',
394            $prefix . 'faqdata_revisions',
395            $prefix . 'faqdata_tags',
396            $prefix . 'faqdata_user',
397            $prefix . 'faqforms',
398            $prefix . 'faqglossary',
399            $prefix . 'faqgroup',
400            $prefix . 'faqgroup_right',
401            $prefix . 'faqinstances',
402            $prefix . 'faqinstances_config',
403            $prefix . 'faqnews',
404            $prefix . 'faqquestions',
405            $prefix . 'faqright',
406            $prefix . 'faqsearches',
407            $prefix . 'faqseo',
408            $prefix . 'faqsessions',
409            $prefix . 'faqstopwords',
410            $prefix . 'faqtags',
411            $prefix . 'faquser',
412            $prefix . 'faquserdata',
413            $prefix . 'faquserlogin',
414            $prefix . 'faquser_group',
415            $prefix . 'faquser_right',
416            $prefix . 'faqvisits',
417            $prefix . 'faqvoting',
418        ];
419    }
420
421    /**
422     * Closes the connection to the database.
423     */
424    public function close(): bool
425    {
426        /* @mago-expect analysis:possibly-invalid-argument - the pg_* functions are shadowed in tests; the stubbed connection must pass through */
427        return pg_close($this->conn);
428    }
429
430    /**
431     * Returns the ID of the last inserted row.
432     */
433    public function lastInsertId(): int|string
434    {
435        /* @mago-expect analysis:possibly-invalid-argument - the pg_* functions are shadowed in tests; the stubbed connection must pass through */
436        $result = pg_query($this->conn, query: 'SELECT lastval()');
437        if ($result === false) {
438            return 0;
439        }
440
441        $row = pg_fetch_row($result);
442        if ($row === false) {
443            return 0;
444        }
445
446        return (int) $row[0];
447    }
448
449    public function now(): string
450    {
451        return 'CURRENT_TIMESTAMP';
452    }
453}