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

codeigniter4 / CodeIgniter4 / 25637286072

10 May 2026 07:09PM UTC coverage: 88.366% (-0.05%) from 88.418%
25637286072

Pull #10182

github

web-flow
Merge 4a0b49c91 into df9f13771
Pull Request #10182: fix(database): classify prepared query exceptions

77 of 108 new or added lines in 9 files covered. (71.3%)

1 existing line in 1 file now uncovered.

23911 of 27059 relevant lines covered (88.37%)

218.09 hits per line

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

89.45
/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\TableName;
19
use stdClass;
20

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

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

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

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

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

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

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

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

92
    /**
93
     * Checks whether the native database error represents a unique constraint violation.
94
     */
95
    protected function isUniqueConstraintViolation(int|string $code, string $message): bool
96
    {
97
        $code = (string) $code;
55✔
98

99
        if (str_contains($code, '/')) {
55✔
100
            [$sqlstate, $vendorCode] = explode('/', $code, 2);
53✔
101

102
            if ($sqlstate === '23000' && in_array((int) $vendorCode, [2627, 2601], true)) {
53✔
103
                return true;
12✔
104
            }
105
        }
106

107
        $errors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
43✔
108
        if (! is_array($errors)) {
43✔
109
            return false;
5✔
110
        }
111

112
        foreach ($errors as $error) {
38✔
113
            // SQLSTATE 23000 (integrity constraint violation) with SQL Server error
114
            // 2627 (UNIQUE CONSTRAINT or PRIMARY KEY violation) or 2601 (UNIQUE INDEX violation).
115
            if (($error['SQLSTATE'] ?? '') === '23000'
38✔
116
                && in_array($error['code'] ?? 0, [2627, 2601], true)) {
38✔
UNCOV
117
                return true;
×
118
            }
119
        }
120

121
        return false;
38✔
122
    }
123

124
    /**
125
     * Checks whether the native database code represents a retryable transaction failure.
126
     */
127
    protected function isRetryableTransactionErrorCode(int|string $code): bool
128
    {
129
        $vendorCode = (string) (is_string($code) && str_contains($code, '/')
43✔
130
            ? substr($code, strrpos($code, '/') + 1)
41✔
131
            : $code);
2✔
132

133
        if (preg_match('/^\d+$/', $vendorCode) !== 1) {
43✔
134
            return false;
1✔
135
        }
136

137
        return in_array((int) $vendorCode, [1205, 3960], true);
42✔
138
    }
139

140
    /**
141
     * Class constructor
142
     */
143
    public function __construct(array $params)
144
    {
145
        parent::__construct($params);
57✔
146

147
        // This is only supported as of SQLSRV 3.0
148
        if ($this->scrollable === null) {
57✔
149
            $this->scrollable = defined('SQLSRV_CURSOR_CLIENT_BUFFERED') ? SQLSRV_CURSOR_CLIENT_BUFFERED : false;
57✔
150
        }
151
    }
152

153
    /**
154
     * Connect to the database.
155
     *
156
     * @return false|resource
157
     *
158
     * @throws DatabaseException
159
     */
160
    public function connect(bool $persistent = false)
161
    {
162
        $charset = in_array(strtolower($this->charset), ['utf-8', 'utf8'], true) ? 'UTF-8' : SQLSRV_ENC_CHAR;
55✔
163

164
        $connection = [
55✔
165
            'UID'                    => empty($this->username) ? '' : $this->username,
55✔
166
            'PWD'                    => empty($this->password) ? '' : $this->password,
55✔
167
            'Database'               => $this->database,
55✔
168
            'ConnectionPooling'      => $persistent ? 1 : 0,
55✔
169
            'CharacterSet'           => $charset,
55✔
170
            'Encrypt'                => $this->encrypt === true ? 1 : 0,
55✔
171
            'TrustServerCertificate' => $this->trustServerCertificate ? 1 : 0,
55✔
172
            'ReturnDatesAsStrings'   => 1,
55✔
173
        ];
55✔
174

175
        // If the username and password are both empty, assume this is a
176
        // 'Windows Authentication Mode' connection.
177
        if (empty($connection['UID']) && empty($connection['PWD'])) {
55✔
178
            unset($connection['UID'], $connection['PWD']);
×
179
        }
180

181
        if (! str_contains($this->hostname, ',') && $this->port !== '') {
55✔
182
            $this->hostname .= ', ' . $this->port;
53✔
183
        }
184

185
        sqlsrv_configure('WarningsReturnAsErrors', 0);
55✔
186
        $this->connID = sqlsrv_connect($this->hostname, $connection);
55✔
187

188
        if ($this->connID !== false) {
55✔
189
            // Determine how identifiers are escaped
190
            $query = $this->query('SELECT CASE WHEN (@@OPTIONS | 256) = @@OPTIONS THEN 1 ELSE 0 END AS qi');
55✔
191
            $query = $query->getResultObject();
54✔
192

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

196
            return $this->connID;
54✔
197
        }
198

199
        throw new DatabaseException($this->getAllErrorMessages());
1✔
200
    }
201

202
    /**
203
     * For exception message
204
     *
205
     * @internal
206
     */
207
    public function getAllErrorMessages(): string
208
    {
209
        $errors = [];
46✔
210

211
        foreach (sqlsrv_errors() as $error) {
46✔
212
            $errors[] = sprintf(
46✔
213
                '%s SQLSTATE: %s, code: %s',
46✔
214
                $error['message'],
46✔
215
                $error['SQLSTATE'],
46✔
216
                $error['code'],
46✔
217
            );
46✔
218
        }
219

220
        return implode("\n", $errors);
46✔
221
    }
222

223
    /**
224
     * Close the database connection.
225
     *
226
     * @return void
227
     */
228
    protected function _close()
229
    {
230
        sqlsrv_close($this->connID);
3✔
231
    }
232

233
    /**
234
     * Platform-dependant string escape
235
     */
236
    protected function _escapeString(string $str): string
237
    {
238
        return str_replace("'", "''", remove_invisible_characters($str, false));
803✔
239
    }
240

241
    /**
242
     * Insert ID
243
     */
244
    public function insertID(): int
245
    {
246
        return (int) ($this->query('SELECT SCOPE_IDENTITY() AS insert_id')->getRow()->insert_id ?? 0);
88✔
247
    }
248

249
    /**
250
     * Generates the SQL for listing tables in a platform-dependent manner.
251
     *
252
     * @param string|null $tableName If $tableName is provided will return only this table if exists.
253
     */
254
    protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string
255
    {
256
        $sql = 'SELECT [TABLE_NAME] AS "name"'
746✔
257
            . ' FROM [INFORMATION_SCHEMA].[TABLES] '
746✔
258
            . ' WHERE '
746✔
259
            . " [TABLE_SCHEMA] = '" . $this->schema . "'    ";
746✔
260

261
        if ($tableName !== null) {
746✔
262
            return $sql .= ' AND [TABLE_NAME] LIKE ' . $this->escape($tableName);
745✔
263
        }
264

265
        if ($prefixLimit && $this->DBPrefix !== '') {
61✔
266
            $sql .= " AND [TABLE_NAME] LIKE '" . $this->escapeLikeString($this->DBPrefix) . "%' "
×
267
                . sprintf($this->likeEscapeStr, $this->likeEscapeChar);
×
268
        }
269

270
        return $sql;
61✔
271
    }
272

273
    /**
274
     * Generates a platform-specific query string so that the column names can be fetched.
275
     *
276
     * @param string|TableName $table
277
     */
278
    protected function _listColumns($table = ''): string
279
    {
280
        if ($table instanceof TableName) {
8✔
281
            $tableName = $this->escape(strtolower($table->getActualTableName()));
2✔
282
        } else {
283
            $tableName = $this->escape($this->DBPrefix . strtolower($table));
6✔
284
        }
285

286
        return 'SELECT [COLUMN_NAME] '
8✔
287
            . ' FROM [INFORMATION_SCHEMA].[COLUMNS]'
8✔
288
            . ' WHERE  [TABLE_NAME] = ' . $tableName
8✔
289
            . ' AND [TABLE_SCHEMA] = ' . $this->escape($this->schema);
8✔
290
    }
291

292
    /**
293
     * Returns an array of objects with index data
294
     *
295
     * @return array<string, stdClass>
296
     *
297
     * @throws DatabaseException
298
     */
299
    protected function _indexData(string $table): array
300
    {
301
        $sql = 'EXEC sp_helpindex ' . $this->escape($this->schema . '.' . $table);
41✔
302

303
        if (($query = $this->query($sql)) === false) {
41✔
304
            throw new DatabaseException(lang('Database.failGetIndexData'));
×
305
        }
306
        $query = $query->getResultObject();
41✔
307

308
        $retVal = [];
41✔
309

310
        foreach ($query as $row) {
41✔
311
            $obj       = new stdClass();
28✔
312
            $obj->name = $row->index_name;
28✔
313

314
            $_fields     = explode(',', trim($row->index_keys));
28✔
315
            $obj->fields = array_map(trim(...), $_fields);
28✔
316

317
            if (str_contains($row->index_description, 'primary key located on')) {
28✔
318
                $obj->type = 'PRIMARY';
25✔
319
            } else {
320
                $obj->type = (str_contains($row->index_description, 'nonclustered, unique')) ? 'UNIQUE' : 'INDEX';
18✔
321
            }
322

323
            $retVal[$obj->name] = $obj;
28✔
324
        }
325

326
        return $retVal;
41✔
327
    }
328

329
    /**
330
     * Returns an array of objects with Foreign key data
331
     * referenced_object_id  parent_object_id
332
     *
333
     * @return array<string, stdClass>
334
     *
335
     * @throws DatabaseException
336
     */
337
    protected function _foreignKeyData(string $table): array
338
    {
339
        $sql = 'SELECT
5✔
340
                f.name as constraint_name,
341
                OBJECT_NAME (f.parent_object_id) as table_name,
342
                COL_NAME(fc.parent_object_id,fc.parent_column_id) column_name,
343
                OBJECT_NAME(f.referenced_object_id) foreign_table_name,
344
                COL_NAME(fc.referenced_object_id,fc.referenced_column_id) foreign_column_name,
345
                rc.delete_rule,
346
                rc.update_rule,
347
                rc.match_option
348
                FROM
349
                sys.foreign_keys AS f
350
                INNER JOIN sys.foreign_key_columns AS fc ON f.OBJECT_ID = fc.constraint_object_id
351
                INNER JOIN sys.tables t ON t.OBJECT_ID = fc.referenced_object_id
352
                INNER JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc ON rc.CONSTRAINT_NAME = f.name
353
                WHERE OBJECT_NAME (f.parent_object_id) = ' . $this->escape($table);
5✔
354

355
        if (($query = $this->query($sql)) === false) {
5✔
356
            throw new DatabaseException(lang('Database.failGetForeignKeyData'));
×
357
        }
358

359
        $query   = $query->getResultObject();
5✔
360
        $indexes = [];
5✔
361

362
        foreach ($query as $row) {
5✔
363
            $indexes[$row->constraint_name]['constraint_name']       = $row->constraint_name;
4✔
364
            $indexes[$row->constraint_name]['table_name']            = $row->table_name;
4✔
365
            $indexes[$row->constraint_name]['column_name'][]         = $row->column_name;
4✔
366
            $indexes[$row->constraint_name]['foreign_table_name']    = $row->foreign_table_name;
4✔
367
            $indexes[$row->constraint_name]['foreign_column_name'][] = $row->foreign_column_name;
4✔
368
            $indexes[$row->constraint_name]['on_delete']             = $row->delete_rule;
4✔
369
            $indexes[$row->constraint_name]['on_update']             = $row->update_rule;
4✔
370
            $indexes[$row->constraint_name]['match']                 = $row->match_option;
4✔
371
        }
372

373
        return $this->foreignKeyDataToObjects($indexes);
5✔
374
    }
375

376
    /**
377
     * Disables foreign key checks temporarily.
378
     *
379
     * @return string
380
     */
381
    protected function _disableForeignKeyChecks()
382
    {
383
        return 'EXEC sp_MSforeachtable "ALTER TABLE ? NOCHECK CONSTRAINT ALL"';
751✔
384
    }
385

386
    /**
387
     * Enables foreign key checks temporarily.
388
     *
389
     * @return string
390
     */
391
    protected function _enableForeignKeyChecks()
392
    {
393
        return 'EXEC sp_MSforeachtable "ALTER TABLE ? WITH CHECK CHECK CONSTRAINT ALL"';
751✔
394
    }
395

396
    /**
397
     * Returns an array of objects with field data
398
     *
399
     * @return list<stdClass>
400
     *
401
     * @throws DatabaseException
402
     */
403
    protected function _fieldData(string $table): array
404
    {
405
        $sql = 'SELECT
13✔
406
                COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION,
407
                COLUMN_DEFAULT, IS_NULLABLE
408
            FROM INFORMATION_SCHEMA.COLUMNS
409
            WHERE TABLE_NAME= ' . $this->escape(($table));
13✔
410

411
        if (($query = $this->query($sql)) === false) {
13✔
412
            throw new DatabaseException(lang('Database.failGetFieldData'));
×
413
        }
414

415
        $query  = $query->getResultObject();
13✔
416
        $retVal = [];
13✔
417

418
        for ($i = 0, $c = count($query); $i < $c; $i++) {
13✔
419
            $retVal[$i] = new stdClass();
13✔
420

421
            $retVal[$i]->name = $query[$i]->COLUMN_NAME;
13✔
422
            $retVal[$i]->type = $query[$i]->DATA_TYPE;
13✔
423

424
            $retVal[$i]->max_length = $query[$i]->CHARACTER_MAXIMUM_LENGTH > 0
13✔
425
                ? $query[$i]->CHARACTER_MAXIMUM_LENGTH
11✔
426
                : (
13✔
427
                    $query[$i]->CHARACTER_MAXIMUM_LENGTH === -1
10✔
428
                    ? 'max'
1✔
429
                    : $query[$i]->NUMERIC_PRECISION
10✔
430
                );
13✔
431

432
            $retVal[$i]->nullable = $query[$i]->IS_NULLABLE !== 'NO';
13✔
433
            $retVal[$i]->default  = $this->normalizeDefault($query[$i]->COLUMN_DEFAULT);
13✔
434
        }
435

436
        return $retVal;
13✔
437
    }
438

439
    /**
440
     * Normalizes SQL Server COLUMN_DEFAULT values.
441
     * Removes wrapping parentheses and handles basic conversions.
442
     */
443
    private function normalizeDefault(?string $default): ?string
444
    {
445
        if ($default === null) {
13✔
446
            return null;
12✔
447
        }
448

449
        $default = trim($default);
2✔
450

451
        // Remove outer parentheses (handles both single and double wrapping)
452
        while (preg_match('/^\((.*)\)$/', $default, $matches)) {
2✔
453
            $default = trim($matches[1]);
2✔
454
        }
455

456
        // Handle NULL literal
457
        if (strcasecmp($default, 'NULL') === 0) {
2✔
458
            return null;
×
459
        }
460

461
        // Handle string literals - remove quotes and unescape
462
        if (preg_match("/^'(.*)'$/s", $default, $matches)) {
2✔
463
            return str_replace("''", "'", $matches[1]);
×
464
        }
465

466
        return $default;
2✔
467
    }
468

469
    /**
470
     * Begin Transaction
471
     */
472
    protected function _transBegin(): bool
473
    {
474
        return sqlsrv_begin_transaction($this->connID);
44✔
475
    }
476

477
    /**
478
     * Commit Transaction
479
     */
480
    protected function _transCommit(): bool
481
    {
482
        return sqlsrv_commit($this->connID);
16✔
483
    }
484

485
    /**
486
     * Rollback Transaction
487
     */
488
    protected function _transRollback(): bool
489
    {
490
        return sqlsrv_rollback($this->connID);
33✔
491
    }
492

493
    /**
494
     * Returns the last error code and message.
495
     * Must return this format: ['code' => string|int, 'message' => string]
496
     * intval(code) === 0 means "no error".
497
     *
498
     * @return array<string, int|string>
499
     */
500
    public function error(): array
501
    {
502
        $error = [
48✔
503
            'code'    => '00000',
48✔
504
            'message' => '',
48✔
505
        ];
48✔
506

507
        $sqlsrvErrors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
48✔
508

509
        if (! is_array($sqlsrvErrors)) {
48✔
510
            return $error;
2✔
511
        }
512

513
        $sqlsrvError = array_shift($sqlsrvErrors);
46✔
514
        if (isset($sqlsrvError['SQLSTATE'])) {
46✔
515
            $error['code'] = isset($sqlsrvError['code']) ? $sqlsrvError['SQLSTATE'] . '/' . $sqlsrvError['code'] : $sqlsrvError['SQLSTATE'];
46✔
516
        } elseif (isset($sqlsrvError['code'])) {
×
517
            $error['code'] = $sqlsrvError['code'];
×
518
        }
519

520
        if (isset($sqlsrvError['message'])) {
46✔
521
            $error['message'] = $sqlsrvError['message'];
46✔
522
        }
523

524
        return $error;
46✔
525
    }
526

527
    /**
528
     * Returns the total number of rows affected by this query.
529
     */
530
    public function affectedRows(): int
531
    {
532
        if ($this->resultID === false) {
51✔
533
            return 0;
1✔
534
        }
535

536
        return sqlsrv_rows_affected($this->resultID);
50✔
537
    }
538

539
    /**
540
     * Select a specific database table to use.
541
     *
542
     * @return bool
543
     */
544
    public function setDatabase(?string $databaseName = null)
545
    {
546
        if ($databaseName === null || $databaseName === '') {
×
547
            $databaseName = $this->database;
×
548
        }
549

550
        if (empty($this->connID)) {
×
551
            $this->initialize();
×
552
        }
553

554
        if ($this->execute('USE ' . $this->_escapeString($databaseName))) {
×
555
            $this->database  = $databaseName;
×
556
            $this->dataCache = [];
×
557

558
            return true;
×
559
        }
560

561
        return false;
×
562
    }
563

564
    /**
565
     * Executes the query against the database.
566
     *
567
     * @return false|resource
568
     */
569
    protected function execute(string $sql)
570
    {
571
        $stmt = ($this->scrollable === false || $this->isWriteType($sql))
833✔
572
            ? sqlsrv_query($this->connID, $sql)
796✔
573
            : sqlsrv_query($this->connID, $sql, [], ['Scrollable' => $this->scrollable]);
832✔
574

575
        if ($stmt === false) {
833✔
576
            $trace   = debug_backtrace();
41✔
577
            $first   = array_shift($trace);
41✔
578
            $message = $this->getAllErrorMessages();
41✔
579

580
            log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [
41✔
581
                'message' => $message,
41✔
582
                'exFile'  => clean_path($first['file']),
41✔
583
                'exLine'  => $first['line'],
41✔
584
                'trace'   => render_backtrace($trace),
41✔
585
            ]);
41✔
586

587
            $error     = $this->error();
41✔
588
            $exception = $this->createDatabaseException($message, $error['code']);
41✔
589

590
            if ($this->DBDebug) {
41✔
591
                throw $exception;
19✔
592
            }
593

594
            $this->lastException = $exception;
22✔
595
        }
596

597
        return $stmt;
833✔
598
    }
599

600
    /**
601
     * The name of the platform in use (MySQLi, mssql, etc)
602
     */
603
    public function getPlatform(): string
604
    {
605
        return $this->DBDriver;
10✔
606
    }
607

608
    /**
609
     * Returns a string containing the version of the database being used.
610
     */
611
    public function getVersion(): string
612
    {
613
        $info = [];
3✔
614
        if (isset($this->dataCache['version'])) {
3✔
615
            return $this->dataCache['version'];
2✔
616
        }
617

618
        if (! $this->connID) {
1✔
619
            $this->initialize();
1✔
620
        }
621

622
        if (($info = sqlsrv_server_info($this->connID)) === []) {
1✔
623
            return '';
×
624
        }
625

626
        return isset($info['SQLServerVersion']) ? $this->dataCache['version'] = $info['SQLServerVersion'] : '';
1✔
627
    }
628

629
    /**
630
     * Determines if a query is a "write" type.
631
     *
632
     * Overrides BaseConnection::isWriteType, adding additional read query types.
633
     *
634
     * @param string $sql
635
     */
636
    public function isWriteType($sql): bool
637
    {
638
        if (preg_match('/^\s*"?(EXEC\s*sp_rename)\s/i', $sql)) {
833✔
639
            return true;
3✔
640
        }
641

642
        return parent::isWriteType($sql);
833✔
643
    }
644
}
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