Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.68% covered (success)
87.68%
121 / 138
69.23% covered (warning)
69.23%
18 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdoSqlite
87.68% covered (success)
87.68%
121 / 138
69.23% covered (warning)
69.23%
18 / 26
60.66
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
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 connect
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 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
3.14
 numRows
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
11.10
 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
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
6.60
 queryPrepared
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
4.59
 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\PdoSqlite class provides methods and functions for SQLite3 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 PdoSqlite
30 *
31 * @package phpMyFAQ\Database
32 */
33/* @mago-expect lint:too-many-methods - implements the full DatabaseDriver contract like the other PDO drivers */
34class PdoSqlite 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     * Bound parameters per prepared statement, so numRows() can re-execute the
69     * COUNT(*) wrapper with the same bindings. SQLite's PDO driver does not
70     * report row counts for SELECT statements.
71     *
72     * @var \WeakMap<PDOStatement, array<int, string|int|float|null>>
73     */
74    private \WeakMap $preparedParams;
75
76    public function __construct()
77    {
78        $this->preparedParams = new \WeakMap();
79    }
80
81    /**
82     * Connects to the database.
83     *
84     * @return null|bool true, if connected, otherwise false
85     * @throws Exception
86     */
87    public function connect(
88        string $host,
89        string $user,
90        #[SensitiveParameter]
91        string $password,
92        string $database = '',
93        ?int $port = null,
94    ): ?bool {
95        $dsn = 'sqlite:' . $host;
96        try {
97            $this->pdo = new PDO($dsn);
98            $this->pdo()->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
99        } catch (PDOException $pdoException) {
100            throw new Exception($pdoException->getMessage());
101        }
102
103        return true;
104    }
105
106    /**
107     * Returns the error string.
108     */
109    public function error(): string
110    {
111        return $this->pdo?->errorInfo()[2] ?? '';
112    }
113
114    /**
115     * Escapes a string for use in a query.
116     *
117     * Note: Unlike PDO::quote(), this method intentionally does NOT add surrounding quotes.
118     * It mirrors the behavior of other drivers (e.g., Sqlite3::escapeString), returning
119     * only the escaped content so callers can uniformly wrap values in SQL strings.
120     */
121    public function escape(string $string): string
122    {
123        // For SQLite, escaping single quotes by doubling them is enough.
124        return str_replace("'", replace: "''", subject: $string);
125    }
126
127    /**
128     * Fetch a result row as an associative array.
129     */
130    public function fetchArray(mixed $result): ?array
131    {
132        if (!$result instanceof PDOStatement) {
133            return null;
134        }
135
136        $row = $result->fetch(PDO::FETCH_ASSOC);
137
138        return is_array($row) ? $row : null;
139    }
140
141    /**
142     * Fetch a result row.
143     */
144    public function fetchRow(mixed $result): mixed
145    {
146        if (!$result instanceof PDOStatement) {
147            return false;
148        }
149
150        $row = $result->fetch(PDO::FETCH_NUM);
151
152        return is_array($row) ? $row[0] ?? false : false;
153    }
154
155    /**
156     * Fetches a complete result as an object.
157     *
158     * @param mixed $result Result set
159     * @throws Exception
160     * @return list<\stdClass>|null
161     */
162    public function fetchAll(mixed $result): ?array
163    {
164        if (!$result instanceof PDOStatement) {
165            throw new Exception('Error while fetching result: ' . $this->error());
166        }
167
168        /* @mago-expect lint:inline-variable-return - the variable carries the @var type for mago analyze */
169        /** @var list<\stdClass> $rows */
170        $rows = $result->fetchAll(PDO::FETCH_OBJ);
171
172        return $rows;
173    }
174
175    /**
176     * Fetch a result row as an object.
177     * This function fetches a result row as an object.
178     *
179     * @return \stdClass|null
180     *
181     * @throws Exception
182     */
183    public function fetchObject(mixed $result): mixed
184    {
185        if (!$result instanceof PDOStatement) {
186            return null;
187        }
188
189        /** @var \stdClass|false $obj */
190        $obj = $result->fetch(PDO::FETCH_OBJ);
191
192        return $obj === false ? null : $obj;
193    }
194
195    /**
196     * Number of rows in a result.
197     */
198    public function numRows(mixed $result): int
199    {
200        if (!$result instanceof PDOStatement) {
201            return 0;
202        }
203
204        try {
205            $sql = $result->queryString;
206            if ($sql !== '' && preg_match('/^\s*SELECT\b/i', $sql) === 1) {
207                $inner = rtrim($sql, characters: " \t\n\r\0\x0B;");
208                $countSql = 'SELECT COUNT(*) AS c FROM (' . $inner . ') AS _pmf_cnt';
209
210                $stmt = $this->pdo()->prepare($countSql);
211                if ($stmt === false) {
212                    return 0;
213                }
214
215                // Re-bind the original parameters for prepared statements
216                $params = $this->preparedParams->offsetExists($result) ? $this->preparedParams[$result] : [];
217                $stmt->execute($params);
218
219                $row = $stmt->fetch(PDO::FETCH_NUM);
220                return is_array($row) && array_key_exists(0, $row) ? (int) $row[0] : 0;
221            }
222        } catch (\Throwable $exception) {
223            // The driver has no logger; record the failure in the query log instead
224            $this->sqlLog .= '-- SQLite numRows COUNT fallback failed: ' . $exception->getMessage() . "\n";
225        }
226
227        // Fallback: for non-SELECT statements rely on rowCount (INSERT/UPDATE/DELETE)
228        try {
229            return $result->rowCount();
230        } catch (\Throwable) {
231            return 0;
232        }
233    }
234
235    /**
236     * Logs the queries.
237     */
238    public function log(): string
239    {
240        return $this->sqlLog;
241    }
242
243    /**
244     * This function returns the table status.
245     *
246     * @param string $prefix Table prefix
247     * @return string[]
248     */
249    public function getTableStatus(string $prefix = ''): array
250    {
251        $status = [];
252        foreach ($this->getTableNames($prefix) as $table) {
253            $status[$table] = $this->getOne('SELECT count(*) FROM ' . $table);
254        }
255
256        return $status;
257    }
258
259    /**
260     * Returns an array with all table names.
261     *
262     * @todo Have to be refactored because of https://github.com/thorsten/phpMyFAQ/issues/965
263     *
264     * @param string $prefix Table prefix
265     *
266     * @return string[]
267     */
268    public function getTableNames(string $prefix = ''): array
269    {
270        return $this->tableNames = [
271            $prefix . 'faqadminlog',
272            $prefix . 'faqattachment',
273            $prefix . 'faqattachment_file',
274            $prefix . 'faqbackup',
275            $prefix . 'faqbookmarks',
276            $prefix . 'faqcaptcha',
277            $prefix . 'faqcategories',
278            $prefix . 'faqcategoryrelations',
279            $prefix . 'faqcategory_group',
280            $prefix . 'faqcategory_news',
281            $prefix . 'faqcategory_order',
282            $prefix . 'faqcategory_user',
283            $prefix . 'faqchanges',
284            $prefix . 'faqchat_messages',
285            $prefix . 'faqcomments',
286            $prefix . 'faqconfig',
287            $prefix . 'faqcustompages',
288            $prefix . 'faqdata',
289            $prefix . 'faqdata_group',
290            $prefix . 'faqdata_revisions',
291            $prefix . 'faqdata_tags',
292            $prefix . 'faqdata_user',
293            $prefix . 'faqforms',
294            $prefix . 'faqglossary',
295            $prefix . 'faqgroup',
296            $prefix . 'faqgroup_right',
297            $prefix . 'faqinstances',
298            $prefix . 'faqinstances_config',
299            $prefix . 'faqnews',
300            $prefix . 'faqquestions',
301            $prefix . 'faqright',
302            $prefix . 'faqsearches',
303            $prefix . 'faqseo',
304            $prefix . 'faqsessions',
305            $prefix . 'faqstopwords',
306            $prefix . 'faqtags',
307            $prefix . 'faquser',
308            $prefix . 'faquserdata',
309            $prefix . 'faquserlogin',
310            $prefix . 'faquser_group',
311            $prefix . 'faquser_right',
312            $prefix . 'faqvisits',
313            $prefix . 'faqvoting',
314        ];
315    }
316
317    /**
318     * Returns just one row.
319     */
320    private function getOne(string $query): string
321    {
322        $statement = $this->pdo()->prepare($query);
323        if ($statement === false) {
324            throw new Exception(message: 'Failed to prepare the SQLite statement.');
325        }
326
327        $statement->execute();
328
329        $row = $statement->fetch(PDO::FETCH_NUM);
330
331        return is_array($row) ? (string) ($row[0] ?? '') : '';
332    }
333
334    /**
335     * This function is a replacement for MySQL's auto-increment so that
336     * we don't need it anymore.
337     *
338     * @param string $table The name of the table
339     * @param string $column The name of the ID column
340     * @throws Exception
341     */
342    public function nextId(string $table, string $column): int
343    {
344        $query = sprintf('SELECT MAX(%s) AS current_id FROM %s', $column, $table);
345
346        $statement = $this->pdo?->prepare($query);
347        if (!$statement instanceof PDOStatement) {
348            throw new Exception('Cannot prepare query: ' . $query);
349        }
350
351        $statement->execute();
352
353        $current = $statement->fetch(PDO::FETCH_NUM);
354
355        return is_array($current) ? (int) ($current[0] ?? 0) + 1 : 1;
356    }
357
358    /**
359     * This function sends a query to the database.
360     *
361     * @return PDOStatement|false $result
362     * @throws Exception
363     */
364    public function query(string $query, int $offset = 0, int $rowcount = 0): mixed
365    {
366        $this->sqlLog .= $query;
367
368        if (0 < $rowcount) {
369            $query .= sprintf(' LIMIT %d,%d', $offset, $rowcount);
370        }
371
372        try {
373            $result = $this->pdo?->query($query) ?? false;
374        } catch (PDOException $pdoException) {
375            throw new Exception($pdoException->getMessage());
376        }
377
378        if (false === $result) {
379            $this->sqlLog .= ($this->pdo()->errorCode() ?? '') . ': ' . $this->error();
380        }
381
382        $this->lastStatement = $result instanceof PDOStatement ? $result : null;
383
384        return $result;
385    }
386
387    /**
388     * Sends a parameterized query; `?` placeholders are bound by PDO.
389     *
390     * @param array<int, string|int|float|null> $params
391     * @throws Exception
392     */
393    public function queryPrepared(string $query, array $params): PDOStatement
394    {
395        $this->sqlLog .= $query;
396
397        if (!$this->pdo instanceof PDO) {
398            throw new Exception('No database connection available for query: ' . $query);
399        }
400
401        try {
402            $statement = $this->pdo()->prepare($query);
403            if ($statement === false) {
404                throw new Exception('Cannot prepare query: ' . $query);
405            }
406
407            $statement->execute($params);
408        } catch (PDOException $pdoException) {
409            throw new Exception($pdoException->getMessage() . ' in query: ' . $query);
410        }
411
412        $this->preparedParams[$statement] = array_values($params);
413        $this->lastStatement = $statement;
414
415        return $statement;
416    }
417
418    /**
419     * Returns the number of rows affected by the last INSERT, UPDATE, or DELETE query.
420     */
421    public function affectedRows(): int
422    {
423        return $this->lastStatement?->rowCount() ?? 0;
424    }
425
426    /**
427     * Prepares a statement for execution and returns a statement object.
428     *
429     * @param string $query The SQL query
430     * @param array $options The driver options
431     */
432    public function prepare(string $query, array $options = []): PDOStatement|false
433    {
434        return $this->pdo()->prepare($query, $options);
435    }
436
437    /**
438     * Executes a prepared statement.
439     *
440     * @param PDOStatement $pdoStatement The prepared statement
441     * @param array $params The parameters
442     */
443    public function execute(PDOStatement $pdoStatement, array $params = []): bool
444    {
445        return $pdoStatement->execute($params);
446    }
447
448    /**
449     * Returns the client version string.
450     */
451    public function clientVersion(): string
452    {
453        return (string) $this->pdo()->getAttribute(PDO::ATTR_CLIENT_VERSION);
454    }
455
456    /**
457     * Returns the server version string.
458     */
459    public function serverVersion(): string
460    {
461        return (string) $this->pdo()->getAttribute(PDO::ATTR_SERVER_VERSION);
462    }
463
464    /**
465     * Closes the connection to the database.
466     */
467    public function close(): void
468    {
469        $this->pdo = null;
470    }
471
472    public function __destruct()
473    {
474        $this->close();
475    }
476
477    /**
478     * Returns the ID of the last inserted row.
479     */
480    public function lastInsertId(): int|string
481    {
482        return (int) ($this->pdo?->lastInsertId() ?? 0);
483    }
484
485    public function now(): string
486    {
487        return 'CURRENT_TIMESTAMP';
488    }
489}