Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.51% covered (warning)
79.51%
97 / 122
56.00% covered (warning)
56.00%
14 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdoSqlsrv
79.51% covered (warning)
79.51%
97 / 122
56.00% covered (warning)
56.00%
14 / 25
66.01
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
3.33
 error
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 escape
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
6.60
 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
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 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\PdoSqlsrv class provides methods and functions for MS SQL Server and SQL Azure databases with
5 * 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 PdoSqlsrv
31 *
32 * @package phpMyFAQ\Database
33 */
34class PdoSqlsrv 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 = 'sqlsrv:Server=' . $host . ($port ? ',' . $port : '') . (';Database=' . $database);
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 SQL Server, escape single quotes by doubling them (SQL standard)
114        return str_replace(search: "'", replace: "''", subject: $string);
115    }
116
117    /**
118     * Fetch a result row as an associative array.
119     */
120    public function fetchArray(mixed $result): ?array
121    {
122        if (!$result instanceof PDOStatement) {
123            return null;
124        }
125
126        $row = $result->fetch(PDO::FETCH_ASSOC);
127
128        return is_array($row) ? $row : null;
129    }
130
131    /**
132     * Fetch a result row.
133     */
134    public function fetchRow(mixed $result): mixed
135    {
136        if (!$result instanceof PDOStatement) {
137            return false;
138        }
139
140        $row = $result->fetch(PDO::FETCH_NUM);
141
142        return is_array($row) ? $row[0] ?? false : false;
143    }
144
145    /**
146     * Fetches a complete result as an object.
147     *
148     * @param mixed $result Result set
149     * @throws Exception
150     * @return list<\stdClass>|null
151     */
152    public function fetchAll(mixed $result): ?array
153    {
154        if (!$result instanceof PDOStatement) {
155            throw new Exception('Error while fetching result: ' . $this->error());
156        }
157
158        /* @mago-expect lint:inline-variable-return - the variable carries the @var type for mago analyze */
159        /** @var list<\stdClass> $rows */
160        $rows = $result->fetchAll(PDO::FETCH_OBJ);
161
162        return $rows;
163    }
164
165    /**
166     * Fetch a result row as an object.
167     * This function fetches a result row as an object.
168     *
169     * @return \stdClass|false|null
170     *
171     * @throws Exception
172     */
173    public function fetchObject(mixed $result): mixed
174    {
175        if (!$result instanceof PDOStatement) {
176            return false;
177        }
178
179        /* @mago-expect lint:inline-variable-return - the variable carries the @var type for mago analyze */
180        /** @var \stdClass|false|null $row */
181        $row = $result->fetch(PDO::FETCH_OBJ);
182
183        return $row;
184    }
185
186    /**
187     * Number of rows in a result.
188     */
189    public function numRows(mixed $result): int
190    {
191        return $result instanceof PDOStatement ? $result->rowCount() : 0;
192    }
193
194    /**
195     * Logs the queries.
196     */
197    public function log(): string
198    {
199        return $this->sqlLog;
200    }
201
202    /**
203     * This function returns the table status.
204     *
205     * @param string $prefix Table prefix
206     * @return string[]
207     */
208    public function getTableStatus(string $prefix = ''): array
209    {
210        $status = [];
211        foreach ($this->getTableNames($prefix) as $table) {
212            $status[$table] = $this->getOne('SELECT count(*) FROM ' . $table);
213        }
214
215        return $status;
216    }
217
218    /**
219     * Returns an array with all table names.
220     *
221     * @todo Have to be refactored because of https://github.com/thorsten/phpMyFAQ/issues/965
222     *
223     * @param string $prefix Table prefix
224     *
225     * @return string[]
226     */
227    public function getTableNames(string $prefix = ''): array
228    {
229        return $this->tableNames = [
230            $prefix . 'faqadminlog',
231            $prefix . 'faqattachment',
232            $prefix . 'faqattachment_file',
233            $prefix . 'faqbackup',
234            $prefix . 'faqbookmarks',
235            $prefix . 'faqcaptcha',
236            $prefix . 'faqcategories',
237            $prefix . 'faqcategoryrelations',
238            $prefix . 'faqcategory_group',
239            $prefix . 'faqcategory_news',
240            $prefix . 'faqcategory_order',
241            $prefix . 'faqcategory_user',
242            $prefix . 'faqchanges',
243            $prefix . 'faqchat_messages',
244            $prefix . 'faqcomments',
245            $prefix . 'faqconfig',
246            $prefix . 'faqcustompages',
247            $prefix . 'faqdata',
248            $prefix . 'faqdata_group',
249            $prefix . 'faqdata_revisions',
250            $prefix . 'faqdata_tags',
251            $prefix . 'faqdata_user',
252            $prefix . 'faqforms',
253            $prefix . 'faqglossary',
254            $prefix . 'faqgroup',
255            $prefix . 'faqgroup_right',
256            $prefix . 'faqinstances',
257            $prefix . 'faqinstances_config',
258            $prefix . 'faqnews',
259            $prefix . 'faqquestions',
260            $prefix . 'faqright',
261            $prefix . 'faqsearches',
262            $prefix . 'faqseo',
263            $prefix . 'faqsessions',
264            $prefix . 'faqstopwords',
265            $prefix . 'faqtags',
266            $prefix . 'faquser',
267            $prefix . 'faquserdata',
268            $prefix . 'faquserlogin',
269            $prefix . 'faquser_group',
270            $prefix . 'faquser_right',
271            $prefix . 'faqvisits',
272            $prefix . 'faqvoting',
273        ];
274    }
275
276    /**
277     * Returns just one row.
278     */
279    /**
280     * @throws Exception
281     */
282    private function getOne(string $query): string
283    {
284        $statement = $this->pdo()->prepare($query);
285        if ($statement === false) {
286            throw new Exception('Cannot prepare query: ' . $query);
287        }
288
289        $statement->execute();
290
291        $row = $statement->fetch(PDO::FETCH_NUM);
292
293        return is_array($row) ? (string) ($row[0] ?? '') : '';
294    }
295
296    /**
297     * This function is a replacement for MySQL's auto-increment so that
298     * we don't need it anymore.
299     *
300     * @param string $table The name of the table
301     * @param string $column The name of the ID column
302     * @throws Exception
303     */
304    public function nextId(string $table, string $column): int
305    {
306        $query = sprintf('SELECT MAX(%s) AS current_id FROM %s', $column, $table);
307
308        $statement = $this->pdo()->prepare($query);
309        if ($statement === false) {
310            throw new Exception('Cannot prepare query: ' . $query);
311        }
312
313        $statement->execute();
314
315        $current = $statement->fetch(PDO::FETCH_NUM);
316
317        return is_array($current) ? (int) ($current[0] ?? 0) + 1 : 1;
318    }
319
320    /**
321     * This function sends a query to the database.
322     *
323     * @return PDOStatement|false $result
324     * @throws Exception
325     */
326    public function query(string $query, int $offset = 0, int $rowcount = 0): mixed
327    {
328        $this->sqlLog .= $query;
329
330        if (0 < $rowcount) {
331            $query .= sprintf(' OFFSET %d ROWS FETCH NEXT %d ROWS ONLY', $offset, $rowcount);
332        }
333
334        try {
335            $result = $this->pdo()->query($query);
336        } catch (PDOException $pdoException) {
337            throw new Exception($pdoException->getMessage());
338        }
339
340        if (false === $result) {
341            $this->sqlLog .= (string) $this->pdo()->errorCode() . ': ' . $this->error();
342        }
343
344        $this->lastStatement = $result instanceof PDOStatement ? $result : null;
345
346        return $result;
347    }
348
349    /**
350     * Sends a parameterized query; `?` placeholders are bound by PDO.
351     *
352     * @param array<int, string|int|float|null> $params
353     * @throws Exception
354     */
355    public function queryPrepared(string $query, array $params): PDOStatement
356    {
357        $this->sqlLog .= $query;
358
359        if (!$this->pdo instanceof PDO) {
360            throw new Exception('No database connection available for query: ' . $query);
361        }
362
363        try {
364            $statement = $this->pdo()->prepare($query);
365            if ($statement === false) {
366                throw new Exception('Cannot prepare query: ' . $query);
367            }
368
369            $statement->execute($params);
370        } catch (PDOException $pdoException) {
371            throw new Exception($pdoException->getMessage() . ' in query: ' . $query);
372        }
373
374        $this->lastStatement = $statement;
375
376        return $statement;
377    }
378
379    /**
380     * Returns the number of rows affected by the last INSERT, UPDATE, or DELETE query.
381     */
382    public function affectedRows(): int
383    {
384        return $this->lastStatement?->rowCount() ?? 0;
385    }
386
387    /**
388     * Prepares a statement for execution and returns a statement object.
389     *
390     * @param string $query The SQL query
391     * @param array $options The driver options
392     */
393    public function prepare(string $query, array $options = []): PDOStatement|false
394    {
395        return $this->pdo()->prepare($query, $options);
396    }
397
398    /**
399     * Executes a prepared statement.
400     *
401     * @param PDOStatement $pdoStatement The prepared statement
402     * @param array        $params The parameters
403     */
404    public function execute(PDOStatement $pdoStatement, array $params = []): bool
405    {
406        return $pdoStatement->execute($params);
407    }
408
409    /**
410     * Returns the client version string.
411     */
412    public function clientVersion(): string
413    {
414        // pdo_sqlsrv reports the client version as an array of components
415        $clientVersion = $this->pdo()->getAttribute(PDO::ATTR_CLIENT_VERSION);
416        if (is_array($clientVersion)) {
417            return implode(' ', array_map(static fn(mixed $part): string => (string) $part, $clientVersion));
418        }
419
420        return (string) $clientVersion;
421    }
422
423    /**
424     * Returns the server version string.
425     */
426    public function serverVersion(): string
427    {
428        return (string) $this->pdo()->getAttribute(PDO::ATTR_SERVER_VERSION);
429    }
430
431    /**
432     * Closes the connection to the database.
433     */
434    public function close(): void
435    {
436        $this->pdo = null;
437    }
438
439    public function __destruct()
440    {
441        $this->close();
442    }
443
444    /**
445     * Returns the ID of the last inserted row.
446     */
447    public function lastInsertId(): int|string
448    {
449        return (int) $this->pdo()->lastInsertId();
450    }
451
452    public function now(): string
453    {
454        return 'CURRENT_TIMESTAMP';
455    }
456}