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

codeigniter4 / CodeIgniter4 / 17042410688

18 Aug 2025 01:43PM UTC coverage: 84.255% (-0.004%) from 84.259%
17042410688

push

github

web-flow
feat: uniform rendering of stack trace from failed DB operations (#9677)

* Add `render_backtrace()` function

* Add structured trace for all DB drivers

* Add changelog

70 of 72 new or added lines in 7 files covered. (97.22%)

6 existing lines in 1 file now uncovered.

21003 of 24928 relevant lines covered (84.25%)

194.41 hits per line

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

77.6
/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
     * Quoted identifier flag
70
     *
71
     * Whether to use SQL-92 standard quoted identifier
72
     * (double quotes) or brackets for identifier escaping.
73
     *
74
     * @var bool
75
     */
76
    protected $_quoted_identifier = true;
77

78
    /**
79
     * List of reserved identifiers
80
     *
81
     * Identifiers that must NOT be escaped.
82
     *
83
     * @var list<string>
84
     */
85
    protected $_reserved_identifiers = ['*'];
86

87
    /**
88
     * Class constructor
89
     */
90
    public function __construct(array $params)
91
    {
92
        parent::__construct($params);
26✔
93

94
        // This is only supported as of SQLSRV 3.0
95
        if ($this->scrollable === null) {
26✔
96
            $this->scrollable = defined('SQLSRV_CURSOR_CLIENT_BUFFERED') ? SQLSRV_CURSOR_CLIENT_BUFFERED : false;
26✔
97
        }
98
    }
99

100
    /**
101
     * Connect to the database.
102
     *
103
     * @return false|resource
104
     *
105
     * @throws DatabaseException
106
     */
107
    public function connect(bool $persistent = false)
108
    {
109
        $charset = in_array(strtolower($this->charset), ['utf-8', 'utf8'], true) ? 'UTF-8' : SQLSRV_ENC_CHAR;
24✔
110

111
        $connection = [
24✔
112
            'UID'                  => empty($this->username) ? '' : $this->username,
24✔
113
            'PWD'                  => empty($this->password) ? '' : $this->password,
24✔
114
            'Database'             => $this->database,
24✔
115
            'ConnectionPooling'    => $persistent ? 1 : 0,
24✔
116
            'CharacterSet'         => $charset,
24✔
117
            'Encrypt'              => $this->encrypt === true ? 1 : 0,
24✔
118
            'ReturnDatesAsStrings' => 1,
24✔
119
        ];
24✔
120

121
        // If the username and password are both empty, assume this is a
122
        // 'Windows Authentication Mode' connection.
123
        if (empty($connection['UID']) && empty($connection['PWD'])) {
24✔
124
            unset($connection['UID'], $connection['PWD']);
×
125
        }
126

127
        if (! str_contains($this->hostname, ',') && $this->port !== '') {
24✔
128
            $this->hostname .= ', ' . $this->port;
23✔
129
        }
130

131
        sqlsrv_configure('WarningsReturnAsErrors', 0);
24✔
132
        $this->connID = sqlsrv_connect($this->hostname, $connection);
24✔
133

134
        if ($this->connID !== false) {
24✔
135
            // Determine how identifiers are escaped
136
            $query = $this->query('SELECT CASE WHEN (@@OPTIONS | 256) = @@OPTIONS THEN 1 ELSE 0 END AS qi');
24✔
137
            $query = $query->getResultObject();
23✔
138

139
            $this->_quoted_identifier = empty($query) ? false : (bool) $query[0]->qi;
23✔
140
            $this->escapeChar         = ($this->_quoted_identifier) ? '"' : ['[', ']'];
23✔
141

142
            return $this->connID;
23✔
143
        }
144

145
        throw new DatabaseException($this->getAllErrorMessages());
1✔
146
    }
147

148
    /**
149
     * For exception message
150
     *
151
     * @internal
152
     */
153
    public function getAllErrorMessages(): string
154
    {
155
        $errors = [];
34✔
156

157
        foreach (sqlsrv_errors() as $error) {
34✔
158
            $errors[] = sprintf(
34✔
159
                '%s SQLSTATE: %s, code: %s',
34✔
160
                $error['message'],
34✔
161
                $error['SQLSTATE'],
34✔
162
                $error['code'],
34✔
163
            );
34✔
164
        }
165

166
        return implode("\n", $errors);
34✔
167
    }
168

169
    /**
170
     * Keep or establish the connection if no queries have been sent for
171
     * a length of time exceeding the server's idle timeout.
172
     *
173
     * @return void
174
     */
175
    public function reconnect()
176
    {
177
        $this->close();
×
178
        $this->initialize();
×
179
    }
180

181
    /**
182
     * Close the database connection.
183
     *
184
     * @return void
185
     */
186
    protected function _close()
187
    {
188
        sqlsrv_close($this->connID);
1✔
189
    }
190

191
    /**
192
     * Platform-dependant string escape
193
     */
194
    protected function _escapeString(string $str): string
195
    {
196
        return str_replace("'", "''", remove_invisible_characters($str, false));
654✔
197
    }
198

199
    /**
200
     * Insert ID
201
     */
202
    public function insertID(): int
203
    {
204
        return (int) ($this->query('SELECT SCOPE_IDENTITY() AS insert_id')->getRow()->insert_id ?? 0);
83✔
205
    }
206

207
    /**
208
     * Generates the SQL for listing tables in a platform-dependent manner.
209
     *
210
     * @param string|null $tableName If $tableName is provided will return only this table if exists.
211
     */
212
    protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string
213
    {
214
        $sql = 'SELECT [TABLE_NAME] AS "name"'
597✔
215
            . ' FROM [INFORMATION_SCHEMA].[TABLES] '
597✔
216
            . ' WHERE '
597✔
217
            . " [TABLE_SCHEMA] = '" . $this->schema . "'    ";
597✔
218

219
        if ($tableName !== null) {
597✔
220
            return $sql .= ' AND [TABLE_NAME] LIKE ' . $this->escape($tableName);
596✔
221
        }
222

223
        if ($prefixLimit && $this->DBPrefix !== '') {
32✔
224
            $sql .= " AND [TABLE_NAME] LIKE '" . $this->escapeLikeString($this->DBPrefix) . "%' "
×
225
                . sprintf($this->likeEscapeStr, $this->likeEscapeChar);
×
226
        }
227

228
        return $sql;
32✔
229
    }
230

231
    /**
232
     * Generates a platform-specific query string so that the column names can be fetched.
233
     *
234
     * @param string|TableName $table
235
     */
236
    protected function _listColumns($table = ''): string
237
    {
238
        if ($table instanceof TableName) {
8✔
239
            $tableName = $this->escape(strtolower($table->getActualTableName()));
2✔
240
        } else {
241
            $tableName = $this->escape($this->DBPrefix . strtolower($table));
6✔
242
        }
243

244
        return 'SELECT [COLUMN_NAME] '
8✔
245
            . ' FROM [INFORMATION_SCHEMA].[COLUMNS]'
8✔
246
            . ' WHERE  [TABLE_NAME] = ' . $tableName
8✔
247
            . ' AND [TABLE_SCHEMA] = ' . $this->escape($this->schema);
8✔
248
    }
249

250
    /**
251
     * Returns an array of objects with index data
252
     *
253
     * @return array<string, stdClass>
254
     *
255
     * @throws DatabaseException
256
     */
257
    protected function _indexData(string $table): array
258
    {
259
        $sql = 'EXEC sp_helpindex ' . $this->escape($this->schema . '.' . $table);
41✔
260

261
        if (($query = $this->query($sql)) === false) {
41✔
262
            throw new DatabaseException(lang('Database.failGetIndexData'));
×
263
        }
264
        $query = $query->getResultObject();
41✔
265

266
        $retVal = [];
41✔
267

268
        foreach ($query as $row) {
41✔
269
            $obj       = new stdClass();
28✔
270
            $obj->name = $row->index_name;
28✔
271

272
            $_fields     = explode(',', trim($row->index_keys));
28✔
273
            $obj->fields = array_map(static fn ($v): string => trim($v), $_fields);
28✔
274

275
            if (str_contains($row->index_description, 'primary key located on')) {
28✔
276
                $obj->type = 'PRIMARY';
25✔
277
            } else {
278
                $obj->type = (str_contains($row->index_description, 'nonclustered, unique')) ? 'UNIQUE' : 'INDEX';
18✔
279
            }
280

281
            $retVal[$obj->name] = $obj;
28✔
282
        }
283

284
        return $retVal;
41✔
285
    }
286

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

313
        if (($query = $this->query($sql)) === false) {
5✔
314
            throw new DatabaseException(lang('Database.failGetForeignKeyData'));
×
315
        }
316

317
        $query   = $query->getResultObject();
5✔
318
        $indexes = [];
5✔
319

320
        foreach ($query as $row) {
5✔
321
            $indexes[$row->constraint_name]['constraint_name']       = $row->constraint_name;
4✔
322
            $indexes[$row->constraint_name]['table_name']            = $row->table_name;
4✔
323
            $indexes[$row->constraint_name]['column_name'][]         = $row->column_name;
4✔
324
            $indexes[$row->constraint_name]['foreign_table_name']    = $row->foreign_table_name;
4✔
325
            $indexes[$row->constraint_name]['foreign_column_name'][] = $row->foreign_column_name;
4✔
326
            $indexes[$row->constraint_name]['on_delete']             = $row->delete_rule;
4✔
327
            $indexes[$row->constraint_name]['on_update']             = $row->update_rule;
4✔
328
            $indexes[$row->constraint_name]['match']                 = $row->match_option;
4✔
329
        }
330

331
        return $this->foreignKeyDataToObjects($indexes);
5✔
332
    }
333

334
    /**
335
     * Disables foreign key checks temporarily.
336
     *
337
     * @return string
338
     */
339
    protected function _disableForeignKeyChecks()
340
    {
341
        return 'EXEC sp_MSforeachtable "ALTER TABLE ? NOCHECK CONSTRAINT ALL"';
602✔
342
    }
343

344
    /**
345
     * Enables foreign key checks temporarily.
346
     *
347
     * @return string
348
     */
349
    protected function _enableForeignKeyChecks()
350
    {
351
        return 'EXEC sp_MSforeachtable "ALTER TABLE ? WITH CHECK CHECK CONSTRAINT ALL"';
602✔
352
    }
353

354
    /**
355
     * Returns an array of objects with field data
356
     *
357
     * @return list<stdClass>
358
     *
359
     * @throws DatabaseException
360
     */
361
    protected function _fieldData(string $table): array
362
    {
363
        $sql = 'SELECT
13✔
364
                COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION,
365
                COLUMN_DEFAULT, IS_NULLABLE
366
            FROM INFORMATION_SCHEMA.COLUMNS
367
            WHERE TABLE_NAME= ' . $this->escape(($table));
13✔
368

369
        if (($query = $this->query($sql)) === false) {
13✔
370
            throw new DatabaseException(lang('Database.failGetFieldData'));
×
371
        }
372

373
        $query  = $query->getResultObject();
13✔
374
        $retVal = [];
13✔
375

376
        for ($i = 0, $c = count($query); $i < $c; $i++) {
13✔
377
            $retVal[$i] = new stdClass();
13✔
378

379
            $retVal[$i]->name = $query[$i]->COLUMN_NAME;
13✔
380
            $retVal[$i]->type = $query[$i]->DATA_TYPE;
13✔
381

382
            $retVal[$i]->max_length = $query[$i]->CHARACTER_MAXIMUM_LENGTH > 0
13✔
383
                ? $query[$i]->CHARACTER_MAXIMUM_LENGTH
11✔
384
                : (
13✔
385
                    $query[$i]->CHARACTER_MAXIMUM_LENGTH === -1
10✔
386
                    ? 'max'
1✔
387
                    : $query[$i]->NUMERIC_PRECISION
10✔
388
                );
13✔
389

390
            $retVal[$i]->nullable = $query[$i]->IS_NULLABLE !== 'NO';
13✔
391
            $retVal[$i]->default  = $this->normalizeDefault($query[$i]->COLUMN_DEFAULT);
13✔
392
        }
393

394
        return $retVal;
13✔
395
    }
396

397
    /**
398
     * Normalizes SQL Server COLUMN_DEFAULT values.
399
     * Removes wrapping parentheses and handles basic conversions.
400
     */
401
    private function normalizeDefault(?string $default): ?string
402
    {
403
        if ($default === null) {
13✔
404
            return null;
12✔
405
        }
406

407
        $default = trim($default);
2✔
408

409
        // Remove outer parentheses (handles both single and double wrapping)
410
        while (preg_match('/^\((.*)\)$/', $default, $matches)) {
2✔
411
            $default = trim($matches[1]);
2✔
412
        }
413

414
        // Handle NULL literal
415
        if (strcasecmp($default, 'NULL') === 0) {
2✔
416
            return null;
×
417
        }
418

419
        // Handle string literals - remove quotes and unescape
420
        if (preg_match("/^'(.*)'$/s", $default, $matches)) {
2✔
421
            return str_replace("''", "'", $matches[1]);
×
422
        }
423

424
        return $default;
2✔
425
    }
426

427
    /**
428
     * Begin Transaction
429
     */
430
    protected function _transBegin(): bool
431
    {
432
        return sqlsrv_begin_transaction($this->connID);
18✔
433
    }
434

435
    /**
436
     * Commit Transaction
437
     */
438
    protected function _transCommit(): bool
439
    {
440
        return sqlsrv_commit($this->connID);
5✔
441
    }
442

443
    /**
444
     * Rollback Transaction
445
     */
446
    protected function _transRollback(): bool
447
    {
448
        return sqlsrv_rollback($this->connID);
18✔
449
    }
450

451
    /**
452
     * Returns the last error code and message.
453
     * Must return this format: ['code' => string|int, 'message' => string]
454
     * intval(code) === 0 means "no error".
455
     *
456
     * @return array<string, int|string>
457
     */
458
    public function error(): array
459
    {
460
        $error = [
2✔
461
            'code'    => '00000',
2✔
462
            'message' => '',
2✔
463
        ];
2✔
464

465
        $sqlsrvErrors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
2✔
466

467
        if (! is_array($sqlsrvErrors)) {
2✔
468
            return $error;
2✔
469
        }
470

UNCOV
471
        $sqlsrvError = array_shift($sqlsrvErrors);
×
UNCOV
472
        if (isset($sqlsrvError['SQLSTATE'])) {
×
UNCOV
473
            $error['code'] = isset($sqlsrvError['code']) ? $sqlsrvError['SQLSTATE'] . '/' . $sqlsrvError['code'] : $sqlsrvError['SQLSTATE'];
×
474
        } elseif (isset($sqlsrvError['code'])) {
×
475
            $error['code'] = $sqlsrvError['code'];
×
476
        }
477

UNCOV
478
        if (isset($sqlsrvError['message'])) {
×
UNCOV
479
            $error['message'] = $sqlsrvError['message'];
×
480
        }
481

UNCOV
482
        return $error;
×
483
    }
484

485
    /**
486
     * Returns the total number of rows affected by this query.
487
     */
488
    public function affectedRows(): int
489
    {
490
        if ($this->resultID === false) {
48✔
491
            return 0;
1✔
492
        }
493

494
        return sqlsrv_rows_affected($this->resultID);
47✔
495
    }
496

497
    /**
498
     * Select a specific database table to use.
499
     *
500
     * @return bool
501
     */
502
    public function setDatabase(?string $databaseName = null)
503
    {
504
        if ($databaseName === null || $databaseName === '') {
×
505
            $databaseName = $this->database;
×
506
        }
507

508
        if (empty($this->connID)) {
×
509
            $this->initialize();
×
510
        }
511

512
        if ($this->execute('USE ' . $this->_escapeString($databaseName))) {
×
513
            $this->database  = $databaseName;
×
514
            $this->dataCache = [];
×
515

516
            return true;
×
517
        }
518

519
        return false;
×
520
    }
521

522
    /**
523
     * Executes the query against the database.
524
     *
525
     * @return false|resource
526
     */
527
    protected function execute(string $sql)
528
    {
529
        $stmt = ($this->scrollable === false || $this->isWriteType($sql))
680✔
530
            ? sqlsrv_query($this->connID, $sql)
647✔
531
            : sqlsrv_query($this->connID, $sql, [], ['Scrollable' => $this->scrollable]);
679✔
532

533
        if ($stmt === false) {
680✔
534
            $trace = debug_backtrace();
33✔
535
            $first = array_shift($trace);
33✔
536

537
            log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [
33✔
538
                'message' => $this->getAllErrorMessages(),
33✔
539
                'exFile'  => clean_path($first['file']),
33✔
540
                'exLine'  => $first['line'],
33✔
541
                'trace'   => render_backtrace($trace),
33✔
542
            ]);
33✔
543

544
            if ($this->DBDebug) {
33✔
545
                throw new DatabaseException($this->getAllErrorMessages());
15✔
546
            }
547
        }
548

549
        return $stmt;
680✔
550
    }
551

552
    /**
553
     * Returns the last error encountered by this connection.
554
     *
555
     * @return array<string, int|string>
556
     *
557
     * @deprecated Use `error()` instead.
558
     */
559
    public function getError()
560
    {
561
        $error = [
×
562
            'code'    => '00000',
×
563
            'message' => '',
×
564
        ];
×
565

566
        $sqlsrvErrors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
×
567

568
        if (! is_array($sqlsrvErrors)) {
×
569
            return $error;
×
570
        }
571

572
        $sqlsrvError = array_shift($sqlsrvErrors);
×
573
        if (isset($sqlsrvError['SQLSTATE'])) {
×
574
            $error['code'] = isset($sqlsrvError['code']) ? $sqlsrvError['SQLSTATE'] . '/' . $sqlsrvError['code'] : $sqlsrvError['SQLSTATE'];
×
575
        } elseif (isset($sqlsrvError['code'])) {
×
576
            $error['code'] = $sqlsrvError['code'];
×
577
        }
578

579
        if (isset($sqlsrvError['message'])) {
×
580
            $error['message'] = $sqlsrvError['message'];
×
581
        }
582

583
        return $error;
×
584
    }
585

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

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

604
        if (! $this->connID) {
1✔
605
            $this->initialize();
1✔
606
        }
607

608
        if (($info = sqlsrv_server_info($this->connID)) === []) {
1✔
609
            return '';
×
610
        }
611

612
        return isset($info['SQLServerVersion']) ? $this->dataCache['version'] = $info['SQLServerVersion'] : '';
1✔
613
    }
614

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

628
        return parent::isWriteType($sql);
680✔
629
    }
630
}
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