• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 25449128090

06 May 2026 04:54PM UTC coverage: 88.285% (-0.002%) from 88.287%
25449128090

Pull #10162

github

web-flow
Merge 3fe354db9 into 3f912495d
Pull Request #10162: feat: classify retryable transaction exceptions

11 of 13 new or added lines in 6 files covered. (84.62%)

213 existing lines in 6 files now uncovered.

23535 of 26658 relevant lines covered (88.28%)

218.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

70.53
/system/Database/MySQLi/Connection.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Database\MySQLi;
15

16
use CodeIgniter\Database\BaseConnection;
17
use CodeIgniter\Database\Exceptions\DatabaseException;
18
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
19
use CodeIgniter\Database\TableName;
20
use CodeIgniter\Exceptions\LogicException;
21
use mysqli;
22
use mysqli_result;
23
use mysqli_sql_exception;
24
use stdClass;
25
use Throwable;
26

27
/**
28
 * Connection for MySQLi
29
 *
30
 * @extends BaseConnection<mysqli, mysqli_result>
31
 */
32
class Connection extends BaseConnection
33
{
34
    /**
35
     * Database driver
36
     *
37
     * @var string
38
     */
39
    public $DBDriver = 'MySQLi';
40

41
    /**
42
     * DELETE hack flag
43
     *
44
     * Whether to use the MySQL "delete hack" which allows the number
45
     * of affected rows to be shown. Uses a preg_replace when enabled,
46
     * adding a bit more processing to all queries.
47
     *
48
     * @var bool
49
     */
50
    public $deleteHack = true;
51

52
    /**
53
     * Identifier escape character
54
     *
55
     * @var string
56
     */
57
    public $escapeChar = '`';
58

59
    /**
60
     * MySQLi object
61
     *
62
     * Has to be preserved without being assigned to $connId.
63
     *
64
     * @var false|mysqli
65
     */
66
    public $mysqli;
67

68
    /**
69
     * MySQLi constant
70
     *
71
     * For unbuffered queries use `MYSQLI_USE_RESULT`.
72
     *
73
     * Default mode for buffered queries uses `MYSQLI_STORE_RESULT`.
74
     *
75
     * @var int
76
     */
77
    public $resultMode = MYSQLI_STORE_RESULT;
78

79
    /**
80
     * Use MYSQLI_OPT_INT_AND_FLOAT_NATIVE
81
     *
82
     * @var bool
83
     */
84
    public $numberNative = false;
85

86
    /**
87
     * Use MYSQLI_CLIENT_FOUND_ROWS
88
     *
89
     * Whether affectedRows() should return number of rows found,
90
     * or number of rows changed, after an UPDATE query.
91
     *
92
     * @var bool
93
     */
94
    public $foundRows = false;
95

96
    /**
97
     * Strict SQL mode
98
     */
99
    protected bool $strictOn = false;
100

101
    /**
102
     * Checks whether the native database code represents a retryable transaction failure.
103
     */
104
    protected function isRetryableTransactionErrorCode(int|string $code): bool
105
    {
106
        // ER_LOCK_DEADLOCK: InnoDB rolls back the full transaction.
107
        return $code === 1213;
4✔
108
    }
109

110
    /**
111
     * Connect to the database.
112
     *
113
     * @return false|mysqli
114
     *
115
     * @throws DatabaseException
116
     */
117
    public function connect(bool $persistent = false)
118
    {
119
        // Do we have a socket path?
120
        if ($this->hostname[0] === '/') {
62✔
UNCOV
121
            $hostname = null;
×
UNCOV
122
            $port     = null;
×
UNCOV
123
            $socket   = $this->hostname;
×
124
        } else {
125
            $hostname = $persistent ? 'p:' . $this->hostname : $this->hostname;
62✔
126
            $port     = empty($this->port) ? null : $this->port;
62✔
127
            $socket   = '';
62✔
128
        }
129

130
        $clientFlags  = ($this->compress === true) ? MYSQLI_CLIENT_COMPRESS : 0;
62✔
131
        $this->mysqli = mysqli_init();
62✔
132

133
        mysqli_report(MYSQLI_REPORT_ALL & ~MYSQLI_REPORT_INDEX);
62✔
134

135
        $this->mysqli->options(MYSQLI_OPT_CONNECT_TIMEOUT, 10);
62✔
136

137
        if ($this->numberNative === true) {
62✔
138
            $this->mysqli->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, 1);
1✔
139
        }
140

141
        $initCommands = [];
62✔
142

143
        if ($this->strictOn) {
62✔
144
            $initCommands[] = "sql_mode = CONCAT(@@sql_mode, ',', 'STRICT_ALL_TABLES')";
62✔
145
        } else {
UNCOV
146
            $initCommands[] = "sql_mode = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
×
147
                                @@sql_mode,
148
                                'STRICT_ALL_TABLES,', ''),
149
                            ',STRICT_ALL_TABLES', ''),
150
                        'STRICT_ALL_TABLES', ''),
151
                    'STRICT_TRANS_TABLES,', ''),
152
                ',STRICT_TRANS_TABLES', ''),
UNCOV
153
            'STRICT_TRANS_TABLES', '')";
×
154
        }
155

156
        // Set session timezone if configured
157
        $timezoneOffset = $this->getSessionTimezone();
62✔
158
        if ($timezoneOffset !== null) {
62✔
159
            $initCommands[] = "time_zone = '{$timezoneOffset}'";
3✔
160
        }
161

162
        $this->mysqli->options(
62✔
163
            MYSQLI_INIT_COMMAND,
62✔
164
            'SET SESSION ' . implode(', ', $initCommands),
62✔
165
        );
62✔
166

167
        if (is_array($this->encrypt)) {
62✔
UNCOV
168
            $ssl = [];
×
169

UNCOV
170
            if (! empty($this->encrypt['ssl_key'])) {
×
UNCOV
171
                $ssl['key'] = $this->encrypt['ssl_key'];
×
172
            }
UNCOV
173
            if (! empty($this->encrypt['ssl_cert'])) {
×
UNCOV
174
                $ssl['cert'] = $this->encrypt['ssl_cert'];
×
175
            }
176
            if (! empty($this->encrypt['ssl_ca'])) {
×
UNCOV
177
                $ssl['ca'] = $this->encrypt['ssl_ca'];
×
178
            }
179
            if (! empty($this->encrypt['ssl_capath'])) {
×
UNCOV
180
                $ssl['capath'] = $this->encrypt['ssl_capath'];
×
181
            }
182
            if (! empty($this->encrypt['ssl_cipher'])) {
×
UNCOV
183
                $ssl['cipher'] = $this->encrypt['ssl_cipher'];
×
184
            }
185

UNCOV
186
            if ($ssl !== []) {
×
187
                if (isset($this->encrypt['ssl_verify'])) {
×
188
                    if ($this->encrypt['ssl_verify']) {
×
UNCOV
189
                        if (defined('MYSQLI_OPT_SSL_VERIFY_SERVER_CERT')) {
×
190
                            $this->mysqli->options(MYSQLI_OPT_SSL_VERIFY_SERVER_CERT, 1);
×
191
                        }
192
                    }
193
                    // Apparently (when it exists), setting MYSQLI_OPT_SSL_VERIFY_SERVER_CERT
194
                    // to FALSE didn't do anything, so PHP 5.6.16 introduced yet another
195
                    // constant ...
196
                    //
197
                    // https://secure.php.net/ChangeLog-5.php#5.6.16
198
                    // https://bugs.php.net/bug.php?id=68344
UNCOV
199
                    elseif (defined('MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT') && version_compare($this->mysqli->client_info, 'mysqlnd 5.6', '>=')) {
×
UNCOV
200
                        $clientFlags += MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT;
×
201
                    }
202
                }
203

UNCOV
204
                $this->mysqli->ssl_set(
×
UNCOV
205
                    $ssl['key'] ?? null,
×
UNCOV
206
                    $ssl['cert'] ?? null,
×
207
                    $ssl['ca'] ?? null,
×
208
                    $ssl['capath'] ?? null,
×
UNCOV
209
                    $ssl['cipher'] ?? null,
×
UNCOV
210
                );
×
211
            }
212

213
            $clientFlags += MYSQLI_CLIENT_SSL;
×
214
        }
215

216
        if ($this->foundRows) {
62✔
217
            $clientFlags += MYSQLI_CLIENT_FOUND_ROWS;
1✔
218
        }
219

220
        try {
221
            if ($this->mysqli->real_connect(
62✔
222
                $hostname,
62✔
223
                $this->username,
62✔
224
                $this->password,
62✔
225
                $this->database,
62✔
226
                $port,
62✔
227
                $socket,
62✔
228
                $clientFlags,
62✔
229
            )) {
62✔
230
                if (! $this->mysqli->set_charset($this->charset)) {
62✔
UNCOV
231
                    log_message('error', "Database: Unable to set the configured connection charset ('{$this->charset}').");
×
232

UNCOV
233
                    $this->mysqli->close();
×
234

UNCOV
235
                    if ($this->DBDebug) {
×
UNCOV
236
                        throw new DatabaseException('Unable to set client connection character set: ' . $this->charset);
×
237
                    }
238

239
                    return false;
×
240
                }
241

242
                return $this->mysqli;
62✔
243
            }
244
        } catch (Throwable $e) {
1✔
245
            // Clean sensitive information from errors.
246
            $msg = $e->getMessage();
1✔
247

248
            $msg = str_replace($this->username, '****', $msg);
1✔
249
            $msg = str_replace($this->password, '****', $msg);
1✔
250

251
            throw new DatabaseException($msg, $e->getCode(), $e);
1✔
252
        }
253

UNCOV
254
        return false;
×
255
    }
256

257
    /**
258
     * Close the database connection.
259
     *
260
     * @return void
261
     */
262
    protected function _close()
263
    {
264
        $this->connID->close();
3✔
265
    }
266

267
    /**
268
     * Select a specific database table to use.
269
     */
270
    public function setDatabase(string $databaseName): bool
271
    {
UNCOV
272
        if ($databaseName === '') {
×
UNCOV
273
            $databaseName = $this->database;
×
274
        }
275

UNCOV
276
        if (empty($this->connID)) {
×
UNCOV
277
            $this->initialize();
×
278
        }
279

280
        if ($this->connID->select_db($databaseName)) {
×
281
            $this->database = $databaseName;
×
282

UNCOV
283
            return true;
×
284
        }
285

UNCOV
286
        return false;
×
287
    }
288

289
    /**
290
     * Returns a string containing the version of the database being used.
291
     */
292
    public function getVersion(): string
293
    {
294
        if (isset($this->dataCache['version'])) {
4✔
295
            return $this->dataCache['version'];
3✔
296
        }
297

298
        if (empty($this->mysqli)) {
2✔
299
            $this->initialize();
1✔
300
        }
301

302
        return $this->dataCache['version'] = $this->mysqli->server_info;
2✔
303
    }
304

305
    /**
306
     * Executes the query against the database.
307
     *
308
     * @return false|mysqli_result
309
     */
310
    protected function execute(string $sql)
311
    {
312
        while ($this->connID->more_results()) {
845✔
UNCOV
313
            $this->connID->next_result();
×
UNCOV
314
            if ($res = $this->connID->store_result()) {
×
UNCOV
315
                $res->free();
×
316
            }
317
        }
318

319
        try {
320
            return $this->connID->query($this->prepQuery($sql), $this->resultMode);
845✔
321
        } catch (mysqli_sql_exception $e) {
39✔
322
            log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [
39✔
323
                'message' => $e->getMessage(),
39✔
324
                'exFile'  => clean_path($e->getFile()),
39✔
325
                'exLine'  => $e->getLine(),
39✔
326
                'trace'   => render_backtrace($e->getTrace()),
39✔
327
            ]);
39✔
328

329
            // MySQL error 1062: ER_DUP_ENTRY – duplicate key value
330
            $exception = $e->getCode() === 1062
39✔
331
                ? new UniqueConstraintViolationException($e->getMessage(), $e->getCode(), $e)
24✔
332
                : new DatabaseException($e->getMessage(), $e->getCode(), $e);
15✔
333

334
            if ($this->DBDebug) {
39✔
335
                throw $exception;
17✔
336
            }
337

338
            $this->lastException = $exception;
22✔
339
        }
340

341
        return false;
22✔
342
    }
343

344
    /**
345
     * Prep the query. If needed, each database adapter can prep the query string
346
     */
347
    protected function prepQuery(string $sql): string
348
    {
349
        // mysqli_affected_rows() returns 0 for "DELETE FROM TABLE" queries. This hack
350
        // modifies the query so that it a proper number of affected rows is returned.
351
        if ($this->deleteHack === true && preg_match('/^\s*DELETE\s+FROM\s+(\S+)\s*$/i', $sql)) {
845✔
352
            return trim($sql) . ' WHERE 1=1';
3✔
353
        }
354

355
        return $sql;
845✔
356
    }
357

358
    /**
359
     * Returns the total number of rows affected by this query.
360
     */
361
    public function affectedRows(): int
362
    {
363
        return $this->connID->affected_rows ?? 0;
62✔
364
    }
365

366
    /**
367
     * Platform-dependant string escape
368
     */
369
    protected function _escapeString(string $str): string
370
    {
371
        if (! $this->connID) {
816✔
UNCOV
372
            $this->initialize();
×
373
        }
374

375
        return $this->connID->real_escape_string($str);
816✔
376
    }
377

378
    /**
379
     * Escape Like String Direct
380
     * There are a few instances where MySQLi queries cannot take the
381
     * additional "ESCAPE x" parameter for specifying the escape character
382
     * in "LIKE" strings, and this handles those directly with a backslash.
383
     *
384
     * @param list<string>|string $str Input string
385
     *
386
     * @return list<string>|string
387
     */
388
    public function escapeLikeStringDirect($str)
389
    {
390
        if (is_array($str)) {
1✔
UNCOV
391
            foreach ($str as $key => $val) {
×
UNCOV
392
                $str[$key] = $this->escapeLikeStringDirect($val);
×
393
            }
394

UNCOV
395
            return $str;
×
396
        }
397

398
        $str = $this->_escapeString($str);
1✔
399

400
        // Escape LIKE condition wildcards
401
        return str_replace(
1✔
402
            [$this->likeEscapeChar, '%', '_'],
1✔
403
            ['\\' . $this->likeEscapeChar, '\\%', '\\_'],
1✔
404
            $str,
1✔
405
        );
1✔
406
    }
407

408
    /**
409
     * Generates the SQL for listing tables in a platform-dependent manner.
410
     * Uses escapeLikeStringDirect().
411
     *
412
     * @param string|null $tableName If $tableName is provided will return only this table if exists.
413
     */
414
    protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string
415
    {
416
        $sql = 'SHOW TABLES FROM ' . $this->escapeIdentifier($this->database);
760✔
417

418
        if ((string) $tableName !== '') {
760✔
419
            return $sql . ' LIKE ' . $this->escape($tableName);
759✔
420
        }
421

422
        if ($prefixLimit && $this->DBPrefix !== '') {
61✔
UNCOV
423
            return $sql . " LIKE '" . $this->escapeLikeStringDirect($this->DBPrefix) . "%'";
×
424
        }
425

426
        return $sql;
61✔
427
    }
428

429
    /**
430
     * Generates a platform-specific query string so that the column names can be fetched.
431
     *
432
     * @param string|TableName $table
433
     */
434
    protected function _listColumns($table = ''): string
435
    {
436
        $tableName = $this->protectIdentifiers(
8✔
437
            $table,
8✔
438
            true,
8✔
439
            null,
8✔
440
            false,
8✔
441
        );
8✔
442

443
        return 'SHOW COLUMNS FROM ' . $tableName;
8✔
444
    }
445

446
    /**
447
     * Returns an array of objects with field data
448
     *
449
     * @return list<stdClass>
450
     *
451
     * @throws DatabaseException
452
     */
453
    protected function _fieldData(string $table): array
454
    {
455
        $table = $this->protectIdentifiers($table, true, null, false);
14✔
456

457
        if (($query = $this->query('SHOW COLUMNS FROM ' . $table)) === false) {
14✔
UNCOV
458
            throw new DatabaseException(lang('Database.failGetFieldData'));
×
459
        }
460
        $query = $query->getResultObject();
14✔
461

462
        $retVal = [];
14✔
463

464
        for ($i = 0, $c = count($query); $i < $c; $i++) {
14✔
465
            $retVal[$i]       = new stdClass();
14✔
466
            $retVal[$i]->name = $query[$i]->Field;
14✔
467

468
            sscanf($query[$i]->Type, '%[a-z](%d)', $retVal[$i]->type, $retVal[$i]->max_length);
14✔
469

470
            $retVal[$i]->nullable    = $query[$i]->Null === 'YES';
14✔
471
            $retVal[$i]->default     = $query[$i]->Default;
14✔
472
            $retVal[$i]->primary_key = (int) ($query[$i]->Key === 'PRI');
14✔
473
        }
474

475
        return $retVal;
14✔
476
    }
477

478
    /**
479
     * Returns an array of objects with index data
480
     *
481
     * @return array<string, stdClass>
482
     *
483
     * @throws DatabaseException
484
     * @throws LogicException
485
     */
486
    protected function _indexData(string $table): array
487
    {
488
        $table = $this->protectIdentifiers($table, true, null, false);
8✔
489

490
        if (($query = $this->query('SHOW INDEX FROM ' . $table)) === false) {
8✔
UNCOV
491
            throw new DatabaseException(lang('Database.failGetIndexData'));
×
492
        }
493

494
        $indexes = $query->getResultArray();
8✔
495

496
        if ($indexes === []) {
8✔
497
            return [];
3✔
498
        }
499

500
        $keys = [];
7✔
501

502
        foreach ($indexes as $index) {
7✔
503
            if (empty($keys[$index['Key_name']])) {
7✔
504
                $keys[$index['Key_name']]       = new stdClass();
7✔
505
                $keys[$index['Key_name']]->name = $index['Key_name'];
7✔
506

507
                if ($index['Key_name'] === 'PRIMARY') {
7✔
508
                    $type = 'PRIMARY';
5✔
509
                } elseif ($index['Index_type'] === 'FULLTEXT') {
5✔
UNCOV
510
                    $type = 'FULLTEXT';
×
511
                } elseif ($index['Non_unique']) {
5✔
512
                    $type = $index['Index_type'] === 'SPATIAL' ? 'SPATIAL' : 'INDEX';
5✔
513
                } else {
514
                    $type = 'UNIQUE';
3✔
515
                }
516

517
                $keys[$index['Key_name']]->type = $type;
7✔
518
            }
519

520
            $keys[$index['Key_name']]->fields[] = $index['Column_name'];
7✔
521
        }
522

523
        return $keys;
7✔
524
    }
525

526
    /**
527
     * Returns an array of objects with Foreign key data
528
     *
529
     * @return array<string, stdClass>
530
     *
531
     * @throws DatabaseException
532
     */
533
    protected function _foreignKeyData(string $table): array
534
    {
535
        $sql = '
6✔
536
                SELECT
537
                    tc.CONSTRAINT_NAME,
538
                    tc.TABLE_NAME,
539
                    kcu.COLUMN_NAME,
540
                    rc.REFERENCED_TABLE_NAME,
541
                    kcu.REFERENCED_COLUMN_NAME,
542
                    rc.DELETE_RULE,
543
                    rc.UPDATE_RULE,
544
                    rc.MATCH_OPTION
545
                FROM information_schema.table_constraints AS tc
546
                INNER JOIN information_schema.referential_constraints AS rc
547
                    ON tc.constraint_name = rc.constraint_name
548
                    AND tc.constraint_schema = rc.constraint_schema
549
                INNER JOIN information_schema.key_column_usage AS kcu
550
                    ON tc.constraint_name = kcu.constraint_name
551
                    AND tc.constraint_schema = kcu.constraint_schema
552
                WHERE
553
                    tc.constraint_type = ' . $this->escape('FOREIGN KEY') . ' AND
6✔
554
                    tc.table_schema = ' . $this->escape($this->database) . ' AND
6✔
555
                    tc.table_name = ' . $this->escape($table);
6✔
556

557
        if (($query = $this->query($sql)) === false) {
6✔
UNCOV
558
            throw new DatabaseException(lang('Database.failGetForeignKeyData'));
×
559
        }
560

561
        $query   = $query->getResultObject();
6✔
562
        $indexes = [];
6✔
563

564
        foreach ($query as $row) {
6✔
565
            $indexes[$row->CONSTRAINT_NAME]['constraint_name']       = $row->CONSTRAINT_NAME;
5✔
566
            $indexes[$row->CONSTRAINT_NAME]['table_name']            = $row->TABLE_NAME;
5✔
567
            $indexes[$row->CONSTRAINT_NAME]['column_name'][]         = $row->COLUMN_NAME;
5✔
568
            $indexes[$row->CONSTRAINT_NAME]['foreign_table_name']    = $row->REFERENCED_TABLE_NAME;
5✔
569
            $indexes[$row->CONSTRAINT_NAME]['foreign_column_name'][] = $row->REFERENCED_COLUMN_NAME;
5✔
570
            $indexes[$row->CONSTRAINT_NAME]['on_delete']             = $row->DELETE_RULE;
5✔
571
            $indexes[$row->CONSTRAINT_NAME]['on_update']             = $row->UPDATE_RULE;
5✔
572
            $indexes[$row->CONSTRAINT_NAME]['match']                 = $row->MATCH_OPTION;
5✔
573
        }
574

575
        return $this->foreignKeyDataToObjects($indexes);
6✔
576
    }
577

578
    /**
579
     * Returns platform-specific SQL to disable foreign key checks.
580
     *
581
     * @return string
582
     */
583
    protected function _disableForeignKeyChecks()
584
    {
585
        return 'SET FOREIGN_KEY_CHECKS=0';
765✔
586
    }
587

588
    /**
589
     * Returns platform-specific SQL to enable foreign key checks.
590
     *
591
     * @return string
592
     */
593
    protected function _enableForeignKeyChecks()
594
    {
595
        return 'SET FOREIGN_KEY_CHECKS=1';
765✔
596
    }
597

598
    /**
599
     * Returns the last error code and message.
600
     * Must return this format: ['code' => string|int, 'message' => string]
601
     * intval(code) === 0 means "no error".
602
     *
603
     * @return array<string, int|string>
604
     */
605
    public function error(): array
606
    {
607
        if (! empty($this->mysqli->connect_errno)) {
3✔
UNCOV
608
            return [
×
UNCOV
609
                'code'    => $this->mysqli->connect_errno,
×
UNCOV
610
                'message' => $this->mysqli->connect_error,
×
UNCOV
611
            ];
×
612
        }
613

614
        return [
3✔
615
            'code'    => $this->connID->errno,
3✔
616
            'message' => $this->connID->error,
3✔
617
        ];
3✔
618
    }
619

620
    /**
621
     * Insert ID
622
     */
623
    public function insertID(): int
624
    {
625
        return $this->connID->insert_id;
88✔
626
    }
627

628
    /**
629
     * Begin Transaction
630
     */
631
    protected function _transBegin(): bool
632
    {
633
        return $this->connID->begin_transaction();
44✔
634
    }
635

636
    /**
637
     * Commit Transaction
638
     */
639
    protected function _transCommit(): bool
640
    {
641
        return $this->connID->commit();
16✔
642
    }
643

644
    /**
645
     * Rollback Transaction
646
     */
647
    protected function _transRollback(): bool
648
    {
649
        return $this->connID->rollback();
33✔
650
    }
651
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc