• 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

88.78
/system/Database/SQLSRV/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\SQLSRV;
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 stdClass;
21

22
/**
23
 * Connection for SQLSRV
24
 *
25
 * @extends BaseConnection<resource, resource>
26
 */
27
class Connection extends BaseConnection
28
{
29
    /**
30
     * Database driver
31
     *
32
     * @var string
33
     */
34
    public $DBDriver = 'SQLSRV';
35

36
    /**
37
     * Database name
38
     *
39
     * @var string
40
     */
41
    public $database;
42

43
    /**
44
     * Scrollable flag
45
     *
46
     * Determines what cursor type to use when executing queries.
47
     *
48
     * FALSE or SQLSRV_CURSOR_FORWARD would increase performance,
49
     * but would disable num_rows() (and possibly insert_id())
50
     *
51
     * @var false|string
52
     */
53
    public $scrollable;
54

55
    /**
56
     * Identifier escape character
57
     *
58
     * @var string
59
     */
60
    public $escapeChar = '"';
61

62
    /**
63
     * Database schema
64
     *
65
     * @var string
66
     */
67
    public $schema = 'dbo';
68

69
    /**
70
     * Trust server certificate.
71
     */
72
    public bool $trustServerCertificate = false;
73

74
    /**
75
     * Quoted identifier flag
76
     *
77
     * Whether to use SQL-92 standard quoted identifier
78
     * (double quotes) or brackets for identifier escaping.
79
     *
80
     * @var bool
81
     */
82
    protected $_quoted_identifier = true;
83

84
    /**
85
     * List of reserved identifiers
86
     *
87
     * Identifiers that must NOT be escaped.
88
     *
89
     * @var list<string>
90
     */
91
    protected $_reserved_identifiers = ['*'];
92

93
    /**
94
     * Checks whether the native database code represents a retryable transaction failure.
95
     */
96
    protected function isRetryableTransactionErrorCode(int|string $code): bool
97
    {
98
        $vendorCode = (string) (is_string($code) && str_contains($code, '/')
7✔
99
            ? substr($code, strrpos($code, '/') + 1)
5✔
100
            : $code);
2✔
101

102
        if (preg_match('/^\d+$/', $vendorCode) !== 1) {
7✔
NEW
103
            return false;
×
104
        }
105

106
        return in_array((int) $vendorCode, [1205, 3960], true);
7✔
107
    }
108

109
    /**
110
     * Class constructor
111
     */
112
    public function __construct(array $params)
113
    {
114
        parent::__construct($params);
57✔
115

116
        // This is only supported as of SQLSRV 3.0
117
        if ($this->scrollable === null) {
57✔
118
            $this->scrollable = defined('SQLSRV_CURSOR_CLIENT_BUFFERED') ? SQLSRV_CURSOR_CLIENT_BUFFERED : false;
57✔
119
        }
120
    }
121

122
    /**
123
     * Connect to the database.
124
     *
125
     * @return false|resource
126
     *
127
     * @throws DatabaseException
128
     */
129
    public function connect(bool $persistent = false)
130
    {
131
        $charset = in_array(strtolower($this->charset), ['utf-8', 'utf8'], true) ? 'UTF-8' : SQLSRV_ENC_CHAR;
55✔
132

133
        $connection = [
55✔
134
            'UID'                    => empty($this->username) ? '' : $this->username,
55✔
135
            'PWD'                    => empty($this->password) ? '' : $this->password,
55✔
136
            'Database'               => $this->database,
55✔
137
            'ConnectionPooling'      => $persistent ? 1 : 0,
55✔
138
            'CharacterSet'           => $charset,
55✔
139
            'Encrypt'                => $this->encrypt === true ? 1 : 0,
55✔
140
            'TrustServerCertificate' => $this->trustServerCertificate ? 1 : 0,
55✔
141
            'ReturnDatesAsStrings'   => 1,
55✔
142
        ];
55✔
143

144
        // If the username and password are both empty, assume this is a
145
        // 'Windows Authentication Mode' connection.
146
        if (empty($connection['UID']) && empty($connection['PWD'])) {
55✔
UNCOV
147
            unset($connection['UID'], $connection['PWD']);
×
148
        }
149

150
        if (! str_contains($this->hostname, ',') && $this->port !== '') {
55✔
151
            $this->hostname .= ', ' . $this->port;
53✔
152
        }
153

154
        sqlsrv_configure('WarningsReturnAsErrors', 0);
55✔
155
        $this->connID = sqlsrv_connect($this->hostname, $connection);
55✔
156

157
        if ($this->connID !== false) {
55✔
158
            // Determine how identifiers are escaped
159
            $query = $this->query('SELECT CASE WHEN (@@OPTIONS | 256) = @@OPTIONS THEN 1 ELSE 0 END AS qi');
55✔
160
            $query = $query->getResultObject();
54✔
161

162
            $this->_quoted_identifier = empty($query) ? false : (bool) $query[0]->qi;
54✔
163
            $this->escapeChar         = ($this->_quoted_identifier) ? '"' : ['[', ']'];
54✔
164

165
            return $this->connID;
54✔
166
        }
167

168
        throw new DatabaseException($this->getAllErrorMessages());
1✔
169
    }
170

171
    /**
172
     * For exception message
173
     *
174
     * @internal
175
     */
176
    public function getAllErrorMessages(): string
177
    {
178
        $errors = [];
42✔
179

180
        foreach (sqlsrv_errors() as $error) {
42✔
181
            $errors[] = sprintf(
42✔
182
                '%s SQLSTATE: %s, code: %s',
42✔
183
                $error['message'],
42✔
184
                $error['SQLSTATE'],
42✔
185
                $error['code'],
42✔
186
            );
42✔
187
        }
188

189
        return implode("\n", $errors);
42✔
190
    }
191

192
    /**
193
     * Close the database connection.
194
     *
195
     * @return void
196
     */
197
    protected function _close()
198
    {
199
        sqlsrv_close($this->connID);
3✔
200
    }
201

202
    /**
203
     * Platform-dependant string escape
204
     */
205
    protected function _escapeString(string $str): string
206
    {
207
        return str_replace("'", "''", remove_invisible_characters($str, false));
790✔
208
    }
209

210
    /**
211
     * Insert ID
212
     */
213
    public function insertID(): int
214
    {
215
        return (int) ($this->query('SELECT SCOPE_IDENTITY() AS insert_id')->getRow()->insert_id ?? 0);
88✔
216
    }
217

218
    /**
219
     * Generates the SQL for listing tables in a platform-dependent manner.
220
     *
221
     * @param string|null $tableName If $tableName is provided will return only this table if exists.
222
     */
223
    protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string
224
    {
225
        $sql = 'SELECT [TABLE_NAME] AS "name"'
733✔
226
            . ' FROM [INFORMATION_SCHEMA].[TABLES] '
733✔
227
            . ' WHERE '
733✔
228
            . " [TABLE_SCHEMA] = '" . $this->schema . "'    ";
733✔
229

230
        if ($tableName !== null) {
733✔
231
            return $sql .= ' AND [TABLE_NAME] LIKE ' . $this->escape($tableName);
732✔
232
        }
233

234
        if ($prefixLimit && $this->DBPrefix !== '') {
61✔
UNCOV
235
            $sql .= " AND [TABLE_NAME] LIKE '" . $this->escapeLikeString($this->DBPrefix) . "%' "
×
UNCOV
236
                . sprintf($this->likeEscapeStr, $this->likeEscapeChar);
×
237
        }
238

239
        return $sql;
61✔
240
    }
241

242
    /**
243
     * Generates a platform-specific query string so that the column names can be fetched.
244
     *
245
     * @param string|TableName $table
246
     */
247
    protected function _listColumns($table = ''): string
248
    {
249
        if ($table instanceof TableName) {
8✔
250
            $tableName = $this->escape(strtolower($table->getActualTableName()));
2✔
251
        } else {
252
            $tableName = $this->escape($this->DBPrefix . strtolower($table));
6✔
253
        }
254

255
        return 'SELECT [COLUMN_NAME] '
8✔
256
            . ' FROM [INFORMATION_SCHEMA].[COLUMNS]'
8✔
257
            . ' WHERE  [TABLE_NAME] = ' . $tableName
8✔
258
            . ' AND [TABLE_SCHEMA] = ' . $this->escape($this->schema);
8✔
259
    }
260

261
    /**
262
     * Returns an array of objects with index data
263
     *
264
     * @return array<string, stdClass>
265
     *
266
     * @throws DatabaseException
267
     */
268
    protected function _indexData(string $table): array
269
    {
270
        $sql = 'EXEC sp_helpindex ' . $this->escape($this->schema . '.' . $table);
41✔
271

272
        if (($query = $this->query($sql)) === false) {
41✔
UNCOV
273
            throw new DatabaseException(lang('Database.failGetIndexData'));
×
274
        }
275
        $query = $query->getResultObject();
41✔
276

277
        $retVal = [];
41✔
278

279
        foreach ($query as $row) {
41✔
280
            $obj       = new stdClass();
28✔
281
            $obj->name = $row->index_name;
28✔
282

283
            $_fields     = explode(',', trim($row->index_keys));
28✔
284
            $obj->fields = array_map(trim(...), $_fields);
28✔
285

286
            if (str_contains($row->index_description, 'primary key located on')) {
28✔
287
                $obj->type = 'PRIMARY';
25✔
288
            } else {
289
                $obj->type = (str_contains($row->index_description, 'nonclustered, unique')) ? 'UNIQUE' : 'INDEX';
18✔
290
            }
291

292
            $retVal[$obj->name] = $obj;
28✔
293
        }
294

295
        return $retVal;
41✔
296
    }
297

298
    /**
299
     * Returns an array of objects with Foreign key data
300
     * referenced_object_id  parent_object_id
301
     *
302
     * @return array<string, stdClass>
303
     *
304
     * @throws DatabaseException
305
     */
306
    protected function _foreignKeyData(string $table): array
307
    {
308
        $sql = 'SELECT
5✔
309
                f.name as constraint_name,
310
                OBJECT_NAME (f.parent_object_id) as table_name,
311
                COL_NAME(fc.parent_object_id,fc.parent_column_id) column_name,
312
                OBJECT_NAME(f.referenced_object_id) foreign_table_name,
313
                COL_NAME(fc.referenced_object_id,fc.referenced_column_id) foreign_column_name,
314
                rc.delete_rule,
315
                rc.update_rule,
316
                rc.match_option
317
                FROM
318
                sys.foreign_keys AS f
319
                INNER JOIN sys.foreign_key_columns AS fc ON f.OBJECT_ID = fc.constraint_object_id
320
                INNER JOIN sys.tables t ON t.OBJECT_ID = fc.referenced_object_id
321
                INNER JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc ON rc.CONSTRAINT_NAME = f.name
322
                WHERE OBJECT_NAME (f.parent_object_id) = ' . $this->escape($table);
5✔
323

324
        if (($query = $this->query($sql)) === false) {
5✔
UNCOV
325
            throw new DatabaseException(lang('Database.failGetForeignKeyData'));
×
326
        }
327

328
        $query   = $query->getResultObject();
5✔
329
        $indexes = [];
5✔
330

331
        foreach ($query as $row) {
5✔
332
            $indexes[$row->constraint_name]['constraint_name']       = $row->constraint_name;
4✔
333
            $indexes[$row->constraint_name]['table_name']            = $row->table_name;
4✔
334
            $indexes[$row->constraint_name]['column_name'][]         = $row->column_name;
4✔
335
            $indexes[$row->constraint_name]['foreign_table_name']    = $row->foreign_table_name;
4✔
336
            $indexes[$row->constraint_name]['foreign_column_name'][] = $row->foreign_column_name;
4✔
337
            $indexes[$row->constraint_name]['on_delete']             = $row->delete_rule;
4✔
338
            $indexes[$row->constraint_name]['on_update']             = $row->update_rule;
4✔
339
            $indexes[$row->constraint_name]['match']                 = $row->match_option;
4✔
340
        }
341

342
        return $this->foreignKeyDataToObjects($indexes);
5✔
343
    }
344

345
    /**
346
     * Disables foreign key checks temporarily.
347
     *
348
     * @return string
349
     */
350
    protected function _disableForeignKeyChecks()
351
    {
352
        return 'EXEC sp_MSforeachtable "ALTER TABLE ? NOCHECK CONSTRAINT ALL"';
738✔
353
    }
354

355
    /**
356
     * Enables foreign key checks temporarily.
357
     *
358
     * @return string
359
     */
360
    protected function _enableForeignKeyChecks()
361
    {
362
        return 'EXEC sp_MSforeachtable "ALTER TABLE ? WITH CHECK CHECK CONSTRAINT ALL"';
738✔
363
    }
364

365
    /**
366
     * Returns an array of objects with field data
367
     *
368
     * @return list<stdClass>
369
     *
370
     * @throws DatabaseException
371
     */
372
    protected function _fieldData(string $table): array
373
    {
374
        $sql = 'SELECT
13✔
375
                COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION,
376
                COLUMN_DEFAULT, IS_NULLABLE
377
            FROM INFORMATION_SCHEMA.COLUMNS
378
            WHERE TABLE_NAME= ' . $this->escape(($table));
13✔
379

380
        if (($query = $this->query($sql)) === false) {
13✔
UNCOV
381
            throw new DatabaseException(lang('Database.failGetFieldData'));
×
382
        }
383

384
        $query  = $query->getResultObject();
13✔
385
        $retVal = [];
13✔
386

387
        for ($i = 0, $c = count($query); $i < $c; $i++) {
13✔
388
            $retVal[$i] = new stdClass();
13✔
389

390
            $retVal[$i]->name = $query[$i]->COLUMN_NAME;
13✔
391
            $retVal[$i]->type = $query[$i]->DATA_TYPE;
13✔
392

393
            $retVal[$i]->max_length = $query[$i]->CHARACTER_MAXIMUM_LENGTH > 0
13✔
394
                ? $query[$i]->CHARACTER_MAXIMUM_LENGTH
11✔
395
                : (
13✔
396
                    $query[$i]->CHARACTER_MAXIMUM_LENGTH === -1
10✔
397
                    ? 'max'
1✔
398
                    : $query[$i]->NUMERIC_PRECISION
10✔
399
                );
13✔
400

401
            $retVal[$i]->nullable = $query[$i]->IS_NULLABLE !== 'NO';
13✔
402
            $retVal[$i]->default  = $this->normalizeDefault($query[$i]->COLUMN_DEFAULT);
13✔
403
        }
404

405
        return $retVal;
13✔
406
    }
407

408
    /**
409
     * Normalizes SQL Server COLUMN_DEFAULT values.
410
     * Removes wrapping parentheses and handles basic conversions.
411
     */
412
    private function normalizeDefault(?string $default): ?string
413
    {
414
        if ($default === null) {
13✔
415
            return null;
12✔
416
        }
417

418
        $default = trim($default);
2✔
419

420
        // Remove outer parentheses (handles both single and double wrapping)
421
        while (preg_match('/^\((.*)\)$/', $default, $matches)) {
2✔
422
            $default = trim($matches[1]);
2✔
423
        }
424

425
        // Handle NULL literal
426
        if (strcasecmp($default, 'NULL') === 0) {
2✔
UNCOV
427
            return null;
×
428
        }
429

430
        // Handle string literals - remove quotes and unescape
431
        if (preg_match("/^'(.*)'$/s", $default, $matches)) {
2✔
UNCOV
432
            return str_replace("''", "'", $matches[1]);
×
433
        }
434

435
        return $default;
2✔
436
    }
437

438
    /**
439
     * Begin Transaction
440
     */
441
    protected function _transBegin(): bool
442
    {
443
        return sqlsrv_begin_transaction($this->connID);
44✔
444
    }
445

446
    /**
447
     * Commit Transaction
448
     */
449
    protected function _transCommit(): bool
450
    {
451
        return sqlsrv_commit($this->connID);
16✔
452
    }
453

454
    /**
455
     * Rollback Transaction
456
     */
457
    protected function _transRollback(): bool
458
    {
459
        return sqlsrv_rollback($this->connID);
33✔
460
    }
461

462
    /**
463
     * Returns the last error code and message.
464
     * Must return this format: ['code' => string|int, 'message' => string]
465
     * intval(code) === 0 means "no error".
466
     *
467
     * @return array<string, int|string>
468
     */
469
    public function error(): array
470
    {
471
        $error = [
43✔
472
            'code'    => '00000',
43✔
473
            'message' => '',
43✔
474
        ];
43✔
475

476
        $sqlsrvErrors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
43✔
477

478
        if (! is_array($sqlsrvErrors)) {
43✔
479
            return $error;
2✔
480
        }
481

482
        $sqlsrvError = array_shift($sqlsrvErrors);
41✔
483
        if (isset($sqlsrvError['SQLSTATE'])) {
41✔
484
            $error['code'] = isset($sqlsrvError['code']) ? $sqlsrvError['SQLSTATE'] . '/' . $sqlsrvError['code'] : $sqlsrvError['SQLSTATE'];
41✔
UNCOV
485
        } elseif (isset($sqlsrvError['code'])) {
×
UNCOV
486
            $error['code'] = $sqlsrvError['code'];
×
487
        }
488

489
        if (isset($sqlsrvError['message'])) {
41✔
490
            $error['message'] = $sqlsrvError['message'];
41✔
491
        }
492

493
        return $error;
41✔
494
    }
495

496
    /**
497
     * Returns the total number of rows affected by this query.
498
     */
499
    public function affectedRows(): int
500
    {
501
        if ($this->resultID === false) {
51✔
502
            return 0;
1✔
503
        }
504

505
        return sqlsrv_rows_affected($this->resultID);
50✔
506
    }
507

508
    /**
509
     * Select a specific database table to use.
510
     *
511
     * @return bool
512
     */
513
    public function setDatabase(?string $databaseName = null)
514
    {
UNCOV
515
        if ($databaseName === null || $databaseName === '') {
×
UNCOV
516
            $databaseName = $this->database;
×
517
        }
518

UNCOV
519
        if (empty($this->connID)) {
×
UNCOV
520
            $this->initialize();
×
521
        }
522

UNCOV
523
        if ($this->execute('USE ' . $this->_escapeString($databaseName))) {
×
UNCOV
524
            $this->database  = $databaseName;
×
UNCOV
525
            $this->dataCache = [];
×
526

UNCOV
527
            return true;
×
528
        }
529

UNCOV
530
        return false;
×
531
    }
532

533
    /**
534
     * Executes the query against the database.
535
     *
536
     * @return false|resource
537
     */
538
    protected function execute(string $sql)
539
    {
540
        $stmt = ($this->scrollable === false || $this->isWriteType($sql))
820✔
541
            ? sqlsrv_query($this->connID, $sql)
783✔
542
            : sqlsrv_query($this->connID, $sql, [], ['Scrollable' => $this->scrollable]);
819✔
543

544
        if ($stmt === false) {
820✔
545
            $trace   = debug_backtrace();
41✔
546
            $first   = array_shift($trace);
41✔
547
            $message = $this->getAllErrorMessages();
41✔
548

549
            log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [
41✔
550
                'message' => $message,
41✔
551
                'exFile'  => clean_path($first['file']),
41✔
552
                'exLine'  => $first['line'],
41✔
553
                'trace'   => render_backtrace($trace),
41✔
554
            ]);
41✔
555

556
            $error     = $this->error();
41✔
557
            $exception = $this->isUniqueConstraintViolation()
41✔
558
                ? new UniqueConstraintViolationException($message, $error['code'])
4✔
559
                : new DatabaseException($message, $error['code']);
37✔
560

561
            if ($this->DBDebug) {
41✔
562
                throw $exception;
19✔
563
            }
564

565
            $this->lastException = $exception;
22✔
566
        }
567

568
        return $stmt;
820✔
569
    }
570

571
    private function isUniqueConstraintViolation(): bool
572
    {
573
        $errors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
41✔
574
        if (! is_array($errors)) {
41✔
UNCOV
575
            return false;
×
576
        }
577

578
        foreach ($errors as $error) {
41✔
579
            // SQLSTATE 23000 (integrity constraint violation) with SQL Server error
580
            // 2627 (UNIQUE CONSTRAINT or PRIMARY KEY violation) or 2601 (UNIQUE INDEX violation).
581
            if (($error['SQLSTATE'] ?? '') === '23000'
41✔
582
                && in_array($error['code'] ?? 0, [2627, 2601], true)) {
41✔
583
                return true;
4✔
584
            }
585
        }
586

587
        return false;
37✔
588
    }
589

590
    /**
591
     * The name of the platform in use (MySQLi, mssql, etc)
592
     */
593
    public function getPlatform(): string
594
    {
595
        return $this->DBDriver;
10✔
596
    }
597

598
    /**
599
     * Returns a string containing the version of the database being used.
600
     */
601
    public function getVersion(): string
602
    {
603
        $info = [];
3✔
604
        if (isset($this->dataCache['version'])) {
3✔
605
            return $this->dataCache['version'];
2✔
606
        }
607

608
        if (! $this->connID) {
1✔
609
            $this->initialize();
1✔
610
        }
611

612
        if (($info = sqlsrv_server_info($this->connID)) === []) {
1✔
613
            return '';
×
614
        }
615

616
        return isset($info['SQLServerVersion']) ? $this->dataCache['version'] = $info['SQLServerVersion'] : '';
1✔
617
    }
618

619
    /**
620
     * Determines if a query is a "write" type.
621
     *
622
     * Overrides BaseConnection::isWriteType, adding additional read query types.
623
     *
624
     * @param string $sql
625
     */
626
    public function isWriteType($sql): bool
627
    {
628
        if (preg_match('/^\s*"?(EXEC\s*sp_rename)\s/i', $sql)) {
820✔
629
            return true;
3✔
630
        }
631

632
        return parent::isWriteType($sql);
820✔
633
    }
634
}
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