Lines 86.42% 121 / 140
Methods 59.09% 13 / 22
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 connection 100.00% 1 / 1 100.00% 1 / 1 1
 connect 100.00% 4 / 4 100.00% 1 / 1 1
 escape 100.00% 1 / 1 100.00% 1 / 1 1
 fetchObject 75.00% 3 / 4 0.00% 0 / 1 3.14
 fetchArray 75.00% 3 / 4 0.00% 0 / 1 3.14
 fetchRow 100.00% 1 / 1 100.00% 1 / 1 2
 fetchAll 88.88% 8 / 9 0.00% 0 / 1 4.02
 error 66.66% 2 / 3 0.00% 0 / 1 3.33
 log 100.00% 1 / 1 100.00% 1 / 1 1
 getTableStatus 100.00% 10 / 10 100.00% 1 / 1 3
 query 66.66% 6 / 9 0.00% 0 / 1 4.59
 queryPrepared 57.89% 11 / 19 0.00% 0 / 1 10.66
 fetchAssoc 75.00% 3 / 4 0.00% 0 / 1 3.14
 numRows 77.77% 7 / 9 0.00% 0 / 1 5.27
 getTableNames 100.00% 45 / 45 100.00% 1 / 1 1
 nextId 100.00% 6 / 6 100.00% 1 / 1 1
 affectedRows 100.00% 1 / 1 100.00% 1 / 1 1
 serverVersion 100.00% 1 / 1 100.00% 1 / 1 1
 clientVersion 100.00% 1 / 1 100.00% 1 / 1 1
 close 80.00% 4 / 5 0.00% 0 / 1 2.03
 lastInsertId 100.00% 1 / 1 100.00% 1 / 1 1
 now 100.00% 1 / 1 100.00% 1 / 1 1
31class Sqlite3 implements DatabaseDriver
32{
33    /**
34     * @var string[] Tables.
35     */
36    public array $tableNames = [];
37
38    /**
39     * The connection object.
40     */
41    private ?\SQLite3 $conn = null;
42
43    /**
44     * Returns the active connection or fails loudly when connect() has not
45     * been called yet or the connection was already closed.
46     */
47    private function connection(): \SQLite3
48    {
49        return $this->conn ?? throw new \RuntimeException('There is no open database connection.');
50    }
51
52    /**
53     * The query log string.
54     * @see query()
55     */
56    private string $sqlLog = '';
57
58    private const string ERROR_MESSAGE =
59        "Do not call numRows() after you've fetched one or more result records, because " . (
60            Sqlite3::class . '::numRows() has to reset the results at its end.'
61        );
62
63    /**
64     * Connects to the database.
65     */
66    public function connect(
67        string $host,
68        string $user,
69        #[SensitiveParameter]
70        string $password,
71        string $database = '',
72        ?int $port = null,
73    ): ?bool {
74        $connection = new \SQLite3($host);
75        $connection->enableExceptions(true);
76        $this->conn = $connection;
77
78        return true;
79    }
80
81    /**
82     * Escapes a string for use in a query.
83     */
84    public function escape(string $string): string
85    {
86        return \SQLite3::escapeString($string);
87    }
88
89    /**
90     * Fetch a result row as an object.
91     *
92     * @return \stdClass|null NULL if there are no more results
93     */
94    public function fetchObject(mixed $result): ?object
95    {
96        if (!$result instanceof \SQLite3Result) {
97            return null;
98        }
99
100        $return = $result->fetchArray(SQLITE3_ASSOC);
101
102        return is_array($return) ? (object) $return : null;
103    }
104
105    /**
106     * Fetch a result row as an array.
107     */
108    public function fetchArray(mixed $result): ?array
109    {
110        if (!$result instanceof \SQLite3Result) {
111            return [];
112        }
113
114        $fetchedData = $result->fetchArray(SQLITE3_ASSOC);
115
116        return is_array($fetchedData) ? $fetchedData : [];
117    }
118
119    /**
120     * Fetch a result row.
121     */
122    public function fetchRow(mixed $result): mixed
123    {
124        return $result instanceof \SQLite3Result ? $result->fetchArray(SQLITE3_ASSOC) : false;
125    }
126
127    /**
128     * Fetches a complete result as an object.
129     *
130     * @param mixed $result Resultset
131     * @return list<\stdClass>|null
132     * @throws Exception
133     */
134    public function fetchAll(mixed $result): ?array
135    {
136        $ret = [];
137        if (!$result instanceof \SQLite3Result) {
138            throw new Exception('Error while fetching result: ' . $this->error());
139        }
140
141        while (true) {
142            $row = $result->fetchArray(SQLITE3_ASSOC);
143            if (!is_array($row)) {
144                break;
145            }
146
147            $ret[] = (object) $row;
148        }
149
150        return $ret;
151    }
152
153    /**
154     * Returns the error string.
155     */
156    public function error(): string
157    {
158        if (!$this->conn instanceof \SQLite3 || 0 === $this->conn->lastErrorCode()) {
159            return '';
160        }
161
162        return $this->conn->lastErrorMsg();
163    }
164
165    /**
166     * Logs the queries.
167     */
168    public function log(): string
169    {
170        return $this->sqlLog;
171    }
172
173    /**
174     * This function returns the table status.
175     *
176     * @param string $prefix Table prefix
177     * @throws Exception
178     */
179    public function getTableStatus(string $prefix = ''): array
180    {
181        $arr = [];
182
183        // Use sqlite_schema (preferred) instead of sqlite_master to avoid linter complaints and for newer SQLite
184        $result = $this->query("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name");
185        while (true) {
186            $row = $this->fetchAssoc($result);
187            if ($row === []) {
188                break;
189            }
190
191            $tableName = (string) $row['name'];
192            $numResult = $this->query(sprintf('SELECT * FROM %s', $tableName));
193            $arr[$tableName] = $this->numRows($numResult);
194        }
195
196        return $arr;
197    }
198
199    /**
200     * This function sends a query to the database.
201     *
202     * @return \SQLite3Result|bool $result
203     */
204    public function query(string $query, int $offset = 0, int $rowcount = 0): \SQLite3Result|bool
205    {
206        $this->sqlLog .= $query;
207
208        if (0 < $rowcount) {
209            $query .= sprintf(' LIMIT %d,%d', $offset, $rowcount);
210        }
211
212        try {
213            $result = $this->connection()->query($query);
214        } catch (\SQLite3Exception) {
215            $result = false;
216        }
217
218        if (!$result) {
219            $this->sqlLog .= $this->error();
220        }
221
222        return $result;
223    }
224
225    /**
226     * Sends a parameterized query; `?` placeholders are bound by SQLite3.
227     *
228     * @param array<int, string|int|float|null> $params
229     */
230    public function queryPrepared(string $query, array $params): \SQLite3Result|bool
231    {
232        $this->sqlLog .= $query;
233
234        if (!$this->conn instanceof \SQLite3) {
235            return false;
236        }
237
238        try {
239            $statement = $this->conn->prepare($query);
240        } catch (\SQLite3Exception) {
241            $statement = false;
242        }
243
244        if (!$statement instanceof \SQLite3Stmt) {
245            $this->sqlLog .= $this->error();
246
247            return false;
248        }
249
250        $position = 1;
251        foreach ($params as $param) {
252            $statement->bindValue($position, $param);
253            ++$position;
254        }
255
256        try {
257            $result = $statement->execute();
258        } catch (\SQLite3Exception) {
259            $result = false;
260        }
261
262        if ($result === false) {
263            $this->sqlLog .= $this->error();
264        }
265
266        return $result;
267    }
268
269    /**
270     * Fetch a result row as an associate array.
271     */
272    public function fetchAssoc(mixed $result): array
273    {
274        if (!$result instanceof \SQLite3Result) {
275            return [];
276        }
277
278        $fetchedData = $result->fetchArray(SQLITE3_ASSOC);
279
280        return is_array($fetchedData) ? $fetchedData : [];
281    }
282
283    /**
284     * Number of rows in a result.
285     * @throws Exception
286     */
287    public function numRows(mixed $result): int
288    {
289        if (!$result instanceof \SQLite3Result) {
290            return 0;
291        }
292
293        if (property_exists($result, 'fetchedByPMF') && (bool) $result->fetchedByPMF) {
294            throw new Exception(self::ERROR_MESSAGE);
295        }
296
297        $numberOfRows = 0;
298        while ($result->fetchArray(SQLITE3_NUM)) {
299            ++$numberOfRows;
300        }
301
302        $result->reset();
303
304        return $numberOfRows;
305    }
306
307    /**
308     * Returns an array with all table names.
309     *
310     * @todo Have to be refactored because of https://github.com/thorsten/phpMyFAQ/issues/965
311     *
312     * @param string $prefix Table prefix
313     *
314     * @return string[]
315     */
316    public function getTableNames(string $prefix = ''): array
317    {
318        return $this->tableNames = [
319            $prefix . 'faqadminlog',
320            $prefix . 'faqattachment',
321            $prefix . 'faqattachment_file',
322            $prefix . 'faqbackup',
323            $prefix . 'faqbookmarks',
324            $prefix . 'faqcaptcha',
325            $prefix . 'faqcategories',
326            $prefix . 'faqcategoryrelations',
327            $prefix . 'faqcategory_group',
328            $prefix . 'faqcategory_news',
329            $prefix . 'faqcategory_order',
330            $prefix . 'faqcategory_user',
331            $prefix . 'faqchanges',
332            $prefix . 'faqchat_messages',
333            $prefix . 'faqcomments',
334            $prefix . 'faqconfig',
335            $prefix . 'faqcustompages',
336            $prefix . 'faqdata',
337            $prefix . 'faqdata_group',
338            $prefix . 'faqdata_revisions',
339            $prefix . 'faqdata_tags',
340            $prefix . 'faqdata_user',
341            $prefix . 'faqforms',
342            $prefix . 'faqglossary',
343            $prefix . 'faqgroup',
344            $prefix . 'faqgroup_right',
345            $prefix . 'faqinstances',
346            $prefix . 'faqinstances_config',
347            $prefix . 'faqnews',
348            $prefix . 'faqquestions',
349            $prefix . 'faqright',
350            $prefix . 'faqsearches',
351            $prefix . 'faqseo',
352            $prefix . 'faqsessions',
353            $prefix . 'faqstopwords',
354            $prefix . 'faqtags',
355            $prefix . 'faquser',
356            $prefix . 'faquserdata',
357            $prefix . 'faquserlogin',
358            $prefix . 'faquser_group',
359            $prefix . 'faquser_right',
360            $prefix . 'faqvisits',
361            $prefix . 'faqvoting',
362        ];
363    }
364
365    /**
366     * Returns the next ID of a table.
367     *
368     * @param string $table the name of the table
369     * @param string $column the name of the ID column
370     */
371    public function nextId(string $table, string $column): int
372    {
373        $result = (int) $this->connection()->querySingle(sprintf(
374            'SELECT max(%s) AS current_id FROM %s',
375            $column,
376            $table,
377        ));
378
379        return $result + 1;
380    }
381
382    /**
383     * Returns the number of rows affected by the last INSERT, UPDATE, or DELETE query.
384     */
385    public function affectedRows(): int
386    {
387        return $this->connection()->changes();
388    }
389
390    /**
391     * Returns the library version string.
392     */
393    public function serverVersion(): string
394    {
395        return $this->clientVersion();
396    }
397
398    /**
399     * Returns the library version string.
400     */
401    public function clientVersion(): string
402    {
403        return (string) \SQLite3::version()['versionString'];
404    }
405
406    /**
407     * Closes the connection to the database.
408     */
409    public function close(): bool
410    {
411        if (!$this->conn instanceof \SQLite3) {
412            return true;
413        }
414
415        $closed = $this->conn->close();
416        $this->conn = null;
417
418        return $closed;
419    }
420
421    /**
422     * Returns the ID of the last inserted row.
423     */
424    public function lastInsertId(): int|string
425    {
426        return $this->connection()->lastInsertRowID();
427    }
428
429    public function now(): string
430    {
431        return "DATETIME('now', 'localtime')";
432    }
433}