Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
146 / 146
100.00% covered (success)
100.00%
30 / 30
CRAP
100.00% covered (success)
100.00%
1 / 1
MydbMysqli
100.00% covered (success)
100.00%
146 / 146
100.00% covered (success)
100.00%
30 / 30
87
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 init
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setTransportOptions
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 setTransactionIsolationLevel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isTransactionOpen
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isConnected
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getMysqli
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 realQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 readServerResponse
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 realEscapeString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 beginTransactionReadwrite
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 beginTransactionReadonly
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 rollback
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 commitAndRelease
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 commit
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 realConnect
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 mysqliReport
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 close
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getConnectErrno
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getConnectError
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isServerGone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getError
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getErrNo
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getAffectedRows
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getInsertId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 autocommit
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 extractServerResponse
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getWarnings
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getFieldCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getWarningCount
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * This file is part of the sshilko/php-sql-mydb package.
4 *
5 * (c) Sergei Shilko <contact@sshilko.com>
6 *
7 * MIT License
8 *
9 * For the full copyright and license information, please view the LICENSE
10 * file that was distributed with this source code.
11 * @license https://opensource.org/licenses/mit-license.php MIT
12 */
13
14declare(strict_types = 1);
15
16namespace sql;
17
18use mysqli;
19use mysqli_result;
20use sql\MydbMysqli\MydbMysqliResult;
21use function array_merge;
22use function array_values;
23use function in_array;
24use function max;
25use function mysqli_report;
26use function sprintf;
27use const MYSQLI_INIT_COMMAND;
28use const MYSQLI_OPT_CONNECT_TIMEOUT;
29use const MYSQLI_OPT_NET_CMD_BUFFER_SIZE;
30use const MYSQLI_OPT_NET_READ_BUFFER_SIZE;
31use const MYSQLI_OPT_READ_TIMEOUT;
32use const MYSQLI_REPORT_ALL;
33use const MYSQLI_REPORT_INDEX;
34use const MYSQLI_REPORT_STRICT;
35use const MYSQLI_STORE_RESULT_COPY_DATA;
36use const MYSQLI_TRANS_COR_NO_RELEASE;
37use const MYSQLI_TRANS_COR_RELEASE;
38use const MYSQLI_TRANS_START_READ_ONLY;
39use const MYSQLI_TRANS_START_READ_WRITE;
40
41/**
42 * Facade for php mysqli extension
43 *
44 * @author Sergei Shilko <contact@sshilko.com>
45 * @license https://opensource.org/licenses/mit-license.php MIT
46 * @see https://github.com/sshilko/php-sql-mydb
47 * @see https://www.php.net/manual/en/class.mysqli
48 */
49class MydbMysqli implements MydbMysqliInterface
50{
51    /**
52     * Command to execute when connecting to MySQL server. Will automatically be re-executed when reconnecting.
53     * @see https://www.php.net/manual/en/mysqli.constants.php
54     */
55    public const MYSQLI_INIT_COMMAND = MYSQLI_INIT_COMMAND;
56
57    /**
58     * Connect timeout in seconds
59     * @see https://www.php.net/manual/en/mysqli.constants.php
60     */
61    public const MYSQLI_OPT_CONNECT_TIMEOUT = MYSQLI_OPT_CONNECT_TIMEOUT;
62
63    /**
64     * The size of the internal command/network buffer. Only valid for mysqlnd.
65     * @see https://www.php.net/manual/en/mysqli.constants.php
66     */
67    public const MYSQLI_OPT_NET_CMD_BUFFER_SIZE = MYSQLI_OPT_NET_CMD_BUFFER_SIZE;
68
69    /**
70     * Maximum read chunk size in bytes when reading the body of a MySQL command packet. Only valid for mysqlnd.
71     * @see https://www.php.net/manual/en/mysqli.constants.php
72     */
73    public const MYSQLI_OPT_NET_READ_BUFFER_SIZE = MYSQLI_OPT_NET_READ_BUFFER_SIZE;
74
75    /**
76     * Command execution result timeout in seconds. Available as of PHP 7.2.0.
77     * @see https://www.php.net/manual/en/mysqli.constants.php
78     */
79    public const MYSQLI_OPT_READ_TIMEOUT = MYSQLI_OPT_READ_TIMEOUT;
80
81    /**
82     * Copy results from the internal mysqlnd buffer into the PHP variables fetched.
83     * By default, mysqlnd will use a reference logic to avoid copying and duplicating results
84     * held in memory. For certain result sets, for example, result sets with many small rows,
85     * the copy approach can reduce the overall memory usage because PHP variables holding
86     * results may be released earlier (available with mysqlnd only)
87     * @see https://www.php.net/manual/en/mysqli.constants.php
88     */
89    public const MYSQLI_STORE_RESULT_COPY_DATA = MYSQLI_STORE_RESULT_COPY_DATA;
90
91    /**
92     * Appends "RELEASE" to mysqli_commit() or mysqli_rollback().
93     * The RELEASE clause causes the server to disconnect the current client session
94     * after terminating the current transaction
95     *
96     * @see https://dev.mysql.com/doc/refman/8.0/en/commit.html
97     * @see https://www.php.net/manual/en/mysqli.constants.php
98     */
99    public const MYSQLI_TRANS_COR_RELEASE = MYSQLI_TRANS_COR_RELEASE;
100
101    /**
102     * Start the transaction as "START TRANSACTION READ ONLY" with mysqli_begin_transaction().
103     *
104     * @see https://www.php.net/manual/en/mysqli.constants.php
105     */
106    public const MYSQLI_TRANS_START_READ_ONLY = MYSQLI_TRANS_START_READ_ONLY;
107
108    /**
109     * Start the transaction as "START TRANSACTION READ WRITE" with mysqli_begin_transaction().
110     * @see https://www.php.net/manual/en/mysqli.constants.php
111     */
112    public const MYSQLI_TRANS_START_READ_WRITE = MYSQLI_TRANS_START_READ_WRITE;
113
114    /**
115     * Appends "NO RELEASE" to mysqli_commit() or mysqli_rollback().
116     * The NO RELEASE clause asks the server to not disconnect the current client session
117     * after terminating the current transaction
118     *
119     * @see https://dev.mysql.com/doc/refman/8.0/en/commit.html
120     * @see https://www.php.net/manual/en/mysqli.constants.php
121     */
122    public const MYSQLI_TRANS_COR_NO_RELEASE = MYSQLI_TRANS_COR_NO_RELEASE;
123
124    /**
125     * Set all options on (report all), report all warnings/errors.
126     *
127     * @see https://www.php.net/manual/en/mysqli.constants.php
128     */
129    public const MYSQLI_REPORT_ALL = MYSQLI_REPORT_ALL;
130
131    /**
132     * Report if no index or bad index was used in a query.
133     *
134     * @see https://www.php.net/manual/en/mysqli.constants.php
135     */
136    public const MYSQLI_REPORT_INDEX = MYSQLI_REPORT_INDEX;
137
138    /**
139     * Throw a mysqli_sql_exception for errors instead of warnings.
140     *
141     * @see https://www.php.net/manual/en/mysqli.constants.php
142     */
143    public const MYSQLI_REPORT_STRICT = MYSQLI_REPORT_STRICT;
144
145    /**
146     * Safe MySQL SQL_MODE
147     * @see https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sqlmode_traditional
148     */
149    protected const SQL_MODE = 'TRADITIONAL';
150
151    /**
152     * Mysqli instance
153     * The mysqli extension allows you to access the functionality provided by MySQL 4.1 and above
154     *
155     * @see http://dev.mysql.com/doc/
156     * @see https://www.php.net/manual/en/mysqli.overview.php
157     * @see https://www.php.net/manual/en/intro.mysqli.php
158     * @see https://www.php.net/manual/en/class.mysqli
159     */
160    protected ?mysqli $mysqli = null;
161
162    /**
163     * Flag indicating whether physical connection was established with remote server
164     * Is connected to remove server
165     */
166    protected bool $isConnected = false;
167
168    /**
169     * Flag indicating whether SQL transaction was started
170     * WARNING: best-effort, only guaranteed when library is used correctly
171     * Is transaction started
172     */
173    protected bool $isTransaction = false;
174
175    public function __construct(?mysqli $resource = null)
176    {
177        if (!$resource) {
178            return;
179        }
180
181        $this->mysqli = $resource;
182    }
183
184    /**
185     * Allocate mysqli resource instance, no physical connection to remote is done
186     *
187     */
188    public function init(): bool
189    {
190        if (null !== $this->mysqli) {
191            /**
192             * Prevent zombie connections
193             */
194            return false;
195        }
196
197        /**
198         * @see https://php.net/manual/en/mysqli.construct.php
199         * @see https://wiki.php.net/rfc/improve_mysqli
200         */
201        $init = new mysqli();
202        $this->mysqli = $init;
203
204        return true;
205    }
206
207    /**
208     * Set various options that affect mysqli resource, before connection is established
209     *
210     * @see https://www.php.net/manual/en/mysqli.options.php
211     * @throws \sql\MydbException\EnvironmentException
212     */
213    public function setTransportOptions(MydbOptionsInterface $options, MydbEnvironmentInterface $environment): bool
214    {
215        if (null === $this->mysqli) {
216            return false;
217        }
218
219        $ignoreUserAbort = $environment->ignore_user_abort();
220        $selectTimeout = $options->getServerSideSelectTimeout();
221
222        /**
223         * Prevent entry of invalid values such as those that are out of range, or NULL specified for NOT NULL columns
224         * TRADITIONAL = strict mode
225         *
226         * @see https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sqlmode_traditional
227         */
228        $mysqliInit = sprintf('SET SESSION sql_mode = %s', self::SQL_MODE);
229        if ($ignoreUserAbort < 1) {
230            $mysqliInit .= sprintf(', SESSION max_execution_time = %s', $selectTimeout * 10000);
231        }
232
233        $connectTimeout = $options->getConnectTimeout();
234        $readTimeout = $options->getReadTimeout();
235        $netReadTimeout = (string) (max($selectTimeout, $readTimeout) + $connectTimeout);
236
237        return
238            $environment->setMysqlndNetReadTimeout($netReadTimeout) &&
239            $this->mysqli->options(self::MYSQLI_INIT_COMMAND, $mysqliInit) &&
240            $this->mysqli->options(self::MYSQLI_OPT_CONNECT_TIMEOUT, $connectTimeout) &&
241            $this->mysqli->options(self::MYSQLI_OPT_READ_TIMEOUT, $readTimeout) &&
242            $this->mysqli->options(self::MYSQLI_OPT_NET_CMD_BUFFER_SIZE, $options->getNetworkBufferSize()) &&
243            $this->mysqli->options(self::MYSQLI_OPT_NET_READ_BUFFER_SIZE, $options->getNetworkReadBuffer());
244    }
245
246    public function setTransactionIsolationLevel(string $level): bool
247    {
248        /**
249         * SESSION is explicitly required,
250         * otherwise 'The statement applies only to the next single transaction performed within the session'
251         *
252         * @see https://dev.mysql.com/doc/refman/8.0/en/set-transaction.html
253         */
254        return $this->realQuery(sprintf('SET SESSION TRANSACTION ISOLATION LEVEL %s', $level));
255    }
256
257    public function isTransactionOpen(): bool
258    {
259        /**
260         * Ignore autocommit setting here
261         */
262        return $this->isTransaction;
263    }
264
265    public function isConnected(): bool
266    {
267        return $this->mysqli && $this->isConnected;
268    }
269
270    public function getMysqli(): ?mysqli
271    {
272        return $this->mysqli;
273    }
274
275    /**
276     * @see https://www.php.net/manual/en/mysqli.real-query.php
277     */
278    public function realQuery(string $query): bool
279    {
280        if ($this->mysqli && $this->isConnected()) {
281            return $this->mysqli->real_query($query);
282        }
283
284        return false;
285    }
286
287    /**
288     * React to mysqli resource changes after query/command execution
289     */
290    public function readServerResponse(MydbEnvironmentInterface $environment): ?MydbMysqliResult
291    {
292        if ($this->mysqli && $this->isConnected()) {
293            $events = [];
294
295            $warnings = [];
296            
297            $result = $this->extractServerResponse($environment, $events);
298
299            $fieldsCount = $this->getFieldCount();
300
301            if ($this->getWarningCount() > 0) {
302                $warnings = array_merge($warnings, $this->getWarnings());
303            }
304            if ($events) {
305                $warnings = array_merge($warnings, array_values($events));
306            }
307
308            /** @var array<array-key, string> $warnings */
309            $response = new MydbMysqliResult($result, $warnings, $fieldsCount ?? 0);
310
311            $error = $this->getError();
312            if (null !== $error && '' !== $error) {
313                $response->setErrorMessage($error);
314            }
315
316            $errno = $this->getErrNo();
317            if ($errno > 0) {
318                $response->setErrorNumber($errno);
319            }
320
321            return $response;
322        }
323
324        return null;
325    }
326
327    /**
328     * @see https://www.php.net/manual/en/mysqli.real-escape-string.php
329     */
330    public function realEscapeString(string $string): ?string
331    {
332        if (!$this->mysqli || !$this->isConnected()) {
333            return null;
334        }
335
336        return $this->mysqli->real_escape_string($string);
337    }
338
339    /**
340     * @see https://www.php.net/manual/en/mysqli.begin-transaction.php
341     */
342    public function beginTransactionReadwrite(): bool
343    {
344        if ($this->mysqli &&
345            $this->isConnected() &&
346            $this->mysqli->begin_transaction(self::MYSQLI_TRANS_START_READ_WRITE)) {
347            $this->isTransaction = true;
348
349            return true;
350        }
351
352        return false;
353    }
354
355    /**
356     * @see https://www.php.net/manual/en/mysqli.begin-transaction.php
357     */
358    public function beginTransactionReadonly(): bool
359    {
360        if ($this->mysqli &&
361            $this->isConnected() &&
362            $this->mysqli->begin_transaction(self::MYSQLI_TRANS_START_READ_ONLY)) {
363            $this->isTransaction = true;
364
365            return true;
366        }
367
368        return false;
369    }
370
371    /**
372     * @see https://www.php.net/manual/en/mysqli.rollback.php
373     */
374    public function rollback(): bool
375    {
376        /**
377         * ignore isTransaction state, do not rely on it, instead do what user requested
378         */
379        if ($this->mysqli && $this->isConnected() && $this->mysqli->rollback(self::MYSQLI_TRANS_COR_NO_RELEASE)) {
380            $this->isTransaction = false;
381
382            return true;
383        }
384
385        return false;
386    }
387
388    /**
389     * Commit transaction and release connection from server side
390     */
391    public function commitAndRelease(): bool
392    {
393        if ($this->mysqli && $this->isConnected() && $this->mysqli->commit(self::MYSQLI_TRANS_COR_RELEASE)) {
394            $this->isTransaction = false;
395
396            return true;
397        }
398
399        return false;
400    }
401
402    public function commit(): bool
403    {
404        if ($this->mysqli && $this->isConnected() && $this->mysqli->commit(self::MYSQLI_TRANS_COR_NO_RELEASE)) {
405            $this->isTransaction = false;
406
407            return true;
408        }
409
410        return false;
411    }
412
413    public function realConnect(
414        string $host,
415        string $username,
416        string $password,
417        string $dbname,
418        ?int $port,
419        ?string $socket,
420        int $flags,
421    ): bool {
422        if ($this->mysqli && !$this->isConnected() && $this->mysqli->real_connect(
423            $host,
424            $username,
425            $password,
426            $dbname,
427            (int) $port,
428            (string) $socket,
429            $flags
430        )) {
431            $this->isConnected = true;
432
433            return true;
434        }
435
436        return false;
437    }
438
439    public function mysqliReport(int $level): bool
440    {
441        return mysqli_report($level);
442    }
443
444    public function close(): bool
445    {
446        if ($this->mysqli) {
447            if ($this->isConnected()) {
448                /**
449                 * Ignore close() success/failure
450                 */
451                $this->mysqli->close();
452                $this->isConnected = false;
453            }
454
455            $this->mysqli = null;
456
457            return true;
458        }
459
460        return false;
461    }
462
463    public function getConnectErrno(): ?int
464    {
465        return $this->mysqli
466            ? $this->mysqli->connect_errno
467            : null;
468    }
469
470    public function getConnectError(): ?string
471    {
472        return $this->mysqli
473            ? $this->mysqli->connect_error
474            : null;
475    }
476
477    public function isServerGone(): bool
478    {
479        return in_array($this->getErrNo(), [2002, 2006], true);
480    }
481
482    public function getError(): ?string
483    {
484        return $this->mysqli
485            ? $this->mysqli->error
486            : null;
487    }
488
489    public function getErrNo(): ?int
490    {
491        return $this->mysqli
492            ? $this->mysqli->errno
493            : null;
494    }
495
496    public function getAffectedRows(): ?int
497    {
498        $rows = $this->mysqli
499            ? (int) $this->mysqli->affected_rows
500            : null;
501        if (0 === $rows || $rows > 0) {
502            return $rows;
503        }
504
505        /**
506         * mysqli_affected_rows
507         * An integer greater than zero indicates the number of rows affected or retrieved.
508         * Zero indicates that no records where updated for an UPDATE statement,
509         * no rows matched the WHERE clause in the query or that no query has yet been executed.
510         * -1 indicates that the query returned an error.
511         */
512        return null;
513    }
514
515    /**
516     * @phpcs:disable SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint
517     */
518    public function getInsertId(): int|string|null
519    {
520        return $this->mysqli
521            ? $this->mysqli->insert_id
522            : null;
523    }
524
525    public function autocommit(bool $enable): bool
526    {
527        if ($this->mysqli && $this->mysqli->autocommit($enable)) {
528            if ($enable) {
529                /**
530                 * Some statement implicitly commit transaction
531                 * @see https://dev.mysql.com/doc/refman/8.0/en/implicit-commit.html
532                 */
533                $this->isTransaction = false;
534            }
535
536            return true;
537        }
538
539        return false;
540    }
541
542    /**
543     * @phpcs:disable SlevomatCodingStandard.PHP.DisallowReference.DisallowedPassingByReference
544     * @param array<int, string> $events
545     */
546    public function extractServerResponse(MydbEnvironmentInterface $environment, array &$events): ?mysqli_result
547    {
548        if (null === $this->mysqli) {
549            return null;
550        }
551
552        /**
553         * @psalm-suppress UnusedClosureParam
554         */
555        $environment->set_error_handler(static function (int $errno, string $error) use (&$events) {
556            $events[$errno] = $error;
557
558            return true;
559        });
560
561        $result = $this->mysqli->store_result(self::MYSQLI_STORE_RESULT_COPY_DATA);
562        $environment->restore_error_handler();
563
564        if (false === $result) {
565            return null;
566        }
567
568        return $result;
569    }
570
571    public function getWarnings(): array
572    {
573        if ($this->mysqli) {
574            $warnings = $this->mysqli->get_warnings();
575            $array = [];
576            do {
577                $array[] = $warnings->message;
578            } while ($warnings->next());
579
580            return $array;
581        }
582
583        return [];
584    }
585
586    /**
587     * Returns fields count caused by query execution
588     * Requires store_result to be called first
589     * @see mysqli::store_result()
590     */
591    protected function getFieldCount(): ?int
592    {
593        return $this->mysqli ? $this->mysqli->field_count : null;
594    }
595
596    /**
597     * Returns warnings caused by query execution
598     * Requires store_result to be called first
599     * @see mysqli::store_result()
600     */
601    protected function getWarningCount(): ?int
602    {
603        return $this->mysqli
604            ? $this->mysqli->warning_count
605            : null;
606    }
607}