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