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

codeigniter4 / CodeIgniter4 / 22348739974

24 Feb 2026 11:24AM UTC coverage: 86.482% (-0.1%) from 86.594%
22348739974

Pull #9979

github

web-flow
Merge c4fa06c86 into 1172c8c94
Pull Request #9979: feat: add `UniqueConstraintViolationException`

95 of 135 new or added lines in 7 files covered. (70.37%)

10 existing lines in 1 file now uncovered.

22404 of 25906 relevant lines covered (86.48%)

217.65 hits per line

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

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

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

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

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

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

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

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

128
        if (! str_contains($this->hostname, ',') && $this->port !== '') {
28✔
129
            $this->hostname .= ', ' . $this->port;
26✔
130
        }
131

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

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

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

143
            return $this->connID;
27✔
144
        }
145

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

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

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

167
        return implode("\n", $errors);
36✔
168
    }
169

170
    /**
171
     * Close the database connection.
172
     *
173
     * @return void
174
     */
175
    protected function _close()
176
    {
177
        sqlsrv_close($this->connID);
3✔
178
    }
179

180
    /**
181
     * Platform-dependant string escape
182
     */
183
    protected function _escapeString(string $str): string
184
    {
185
        return str_replace("'", "''", remove_invisible_characters($str, false));
749✔
186
    }
187

188
    /**
189
     * Insert ID
190
     */
191
    public function insertID(): int
192
    {
193
        return (int) ($this->query('SELECT SCOPE_IDENTITY() AS insert_id')->getRow()->insert_id ?? 0);
83✔
194
    }
195

196
    /**
197
     * Generates the SQL for listing tables in a platform-dependent manner.
198
     *
199
     * @param string|null $tableName If $tableName is provided will return only this table if exists.
200
     */
201
    protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string
202
    {
203
        $sql = 'SELECT [TABLE_NAME] AS "name"'
692✔
204
            . ' FROM [INFORMATION_SCHEMA].[TABLES] '
692✔
205
            . ' WHERE '
692✔
206
            . " [TABLE_SCHEMA] = '" . $this->schema . "'    ";
692✔
207

208
        if ($tableName !== null) {
692✔
209
            return $sql .= ' AND [TABLE_NAME] LIKE ' . $this->escape($tableName);
691✔
210
        }
211

212
        if ($prefixLimit && $this->DBPrefix !== '') {
34✔
213
            $sql .= " AND [TABLE_NAME] LIKE '" . $this->escapeLikeString($this->DBPrefix) . "%' "
×
214
                . sprintf($this->likeEscapeStr, $this->likeEscapeChar);
×
215
        }
216

217
        return $sql;
34✔
218
    }
219

220
    /**
221
     * Generates a platform-specific query string so that the column names can be fetched.
222
     *
223
     * @param string|TableName $table
224
     */
225
    protected function _listColumns($table = ''): string
226
    {
227
        if ($table instanceof TableName) {
8✔
228
            $tableName = $this->escape(strtolower($table->getActualTableName()));
2✔
229
        } else {
230
            $tableName = $this->escape($this->DBPrefix . strtolower($table));
6✔
231
        }
232

233
        return 'SELECT [COLUMN_NAME] '
8✔
234
            . ' FROM [INFORMATION_SCHEMA].[COLUMNS]'
8✔
235
            . ' WHERE  [TABLE_NAME] = ' . $tableName
8✔
236
            . ' AND [TABLE_SCHEMA] = ' . $this->escape($this->schema);
8✔
237
    }
238

239
    /**
240
     * Returns an array of objects with index data
241
     *
242
     * @return array<string, stdClass>
243
     *
244
     * @throws DatabaseException
245
     */
246
    protected function _indexData(string $table): array
247
    {
248
        $sql = 'EXEC sp_helpindex ' . $this->escape($this->schema . '.' . $table);
41✔
249

250
        if (($query = $this->query($sql)) === false) {
41✔
251
            throw new DatabaseException(lang('Database.failGetIndexData'));
×
252
        }
253
        $query = $query->getResultObject();
41✔
254

255
        $retVal = [];
41✔
256

257
        foreach ($query as $row) {
41✔
258
            $obj       = new stdClass();
28✔
259
            $obj->name = $row->index_name;
28✔
260

261
            $_fields     = explode(',', trim($row->index_keys));
28✔
262
            $obj->fields = array_map(trim(...), $_fields);
28✔
263

264
            if (str_contains($row->index_description, 'primary key located on')) {
28✔
265
                $obj->type = 'PRIMARY';
25✔
266
            } else {
267
                $obj->type = (str_contains($row->index_description, 'nonclustered, unique')) ? 'UNIQUE' : 'INDEX';
18✔
268
            }
269

270
            $retVal[$obj->name] = $obj;
28✔
271
        }
272

273
        return $retVal;
41✔
274
    }
275

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

302
        if (($query = $this->query($sql)) === false) {
5✔
303
            throw new DatabaseException(lang('Database.failGetForeignKeyData'));
×
304
        }
305

306
        $query   = $query->getResultObject();
5✔
307
        $indexes = [];
5✔
308

309
        foreach ($query as $row) {
5✔
310
            $indexes[$row->constraint_name]['constraint_name']       = $row->constraint_name;
4✔
311
            $indexes[$row->constraint_name]['table_name']            = $row->table_name;
4✔
312
            $indexes[$row->constraint_name]['column_name'][]         = $row->column_name;
4✔
313
            $indexes[$row->constraint_name]['foreign_table_name']    = $row->foreign_table_name;
4✔
314
            $indexes[$row->constraint_name]['foreign_column_name'][] = $row->foreign_column_name;
4✔
315
            $indexes[$row->constraint_name]['on_delete']             = $row->delete_rule;
4✔
316
            $indexes[$row->constraint_name]['on_update']             = $row->update_rule;
4✔
317
            $indexes[$row->constraint_name]['match']                 = $row->match_option;
4✔
318
        }
319

320
        return $this->foreignKeyDataToObjects($indexes);
5✔
321
    }
322

323
    /**
324
     * Disables foreign key checks temporarily.
325
     *
326
     * @return string
327
     */
328
    protected function _disableForeignKeyChecks()
329
    {
330
        return 'EXEC sp_MSforeachtable "ALTER TABLE ? NOCHECK CONSTRAINT ALL"';
697✔
331
    }
332

333
    /**
334
     * Enables foreign key checks temporarily.
335
     *
336
     * @return string
337
     */
338
    protected function _enableForeignKeyChecks()
339
    {
340
        return 'EXEC sp_MSforeachtable "ALTER TABLE ? WITH CHECK CHECK CONSTRAINT ALL"';
697✔
341
    }
342

343
    /**
344
     * Returns an array of objects with field data
345
     *
346
     * @return list<stdClass>
347
     *
348
     * @throws DatabaseException
349
     */
350
    protected function _fieldData(string $table): array
351
    {
352
        $sql = 'SELECT
13✔
353
                COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION,
354
                COLUMN_DEFAULT, IS_NULLABLE
355
            FROM INFORMATION_SCHEMA.COLUMNS
356
            WHERE TABLE_NAME= ' . $this->escape(($table));
13✔
357

358
        if (($query = $this->query($sql)) === false) {
13✔
359
            throw new DatabaseException(lang('Database.failGetFieldData'));
×
360
        }
361

362
        $query  = $query->getResultObject();
13✔
363
        $retVal = [];
13✔
364

365
        for ($i = 0, $c = count($query); $i < $c; $i++) {
13✔
366
            $retVal[$i] = new stdClass();
13✔
367

368
            $retVal[$i]->name = $query[$i]->COLUMN_NAME;
13✔
369
            $retVal[$i]->type = $query[$i]->DATA_TYPE;
13✔
370

371
            $retVal[$i]->max_length = $query[$i]->CHARACTER_MAXIMUM_LENGTH > 0
13✔
372
                ? $query[$i]->CHARACTER_MAXIMUM_LENGTH
11✔
373
                : (
13✔
374
                    $query[$i]->CHARACTER_MAXIMUM_LENGTH === -1
10✔
375
                    ? 'max'
1✔
376
                    : $query[$i]->NUMERIC_PRECISION
10✔
377
                );
13✔
378

379
            $retVal[$i]->nullable = $query[$i]->IS_NULLABLE !== 'NO';
13✔
380
            $retVal[$i]->default  = $this->normalizeDefault($query[$i]->COLUMN_DEFAULT);
13✔
381
        }
382

383
        return $retVal;
13✔
384
    }
385

386
    /**
387
     * Normalizes SQL Server COLUMN_DEFAULT values.
388
     * Removes wrapping parentheses and handles basic conversions.
389
     */
390
    private function normalizeDefault(?string $default): ?string
391
    {
392
        if ($default === null) {
13✔
393
            return null;
12✔
394
        }
395

396
        $default = trim($default);
2✔
397

398
        // Remove outer parentheses (handles both single and double wrapping)
399
        while (preg_match('/^\((.*)\)$/', $default, $matches)) {
2✔
400
            $default = trim($matches[1]);
2✔
401
        }
402

403
        // Handle NULL literal
404
        if (strcasecmp($default, 'NULL') === 0) {
2✔
405
            return null;
×
406
        }
407

408
        // Handle string literals - remove quotes and unescape
409
        if (preg_match("/^'(.*)'$/s", $default, $matches)) {
2✔
410
            return str_replace("''", "'", $matches[1]);
×
411
        }
412

413
        return $default;
2✔
414
    }
415

416
    /**
417
     * Begin Transaction
418
     */
419
    protected function _transBegin(): bool
420
    {
421
        return sqlsrv_begin_transaction($this->connID);
19✔
422
    }
423

424
    /**
425
     * Commit Transaction
426
     */
427
    protected function _transCommit(): bool
428
    {
429
        return sqlsrv_commit($this->connID);
5✔
430
    }
431

432
    /**
433
     * Rollback Transaction
434
     */
435
    protected function _transRollback(): bool
436
    {
437
        return sqlsrv_rollback($this->connID);
19✔
438
    }
439

440
    /**
441
     * Returns the last error code and message.
442
     * Must return this format: ['code' => string|int, 'message' => string]
443
     * intval(code) === 0 means "no error".
444
     *
445
     * @return array<string, int|string>
446
     */
447
    public function error(): array
448
    {
449
        $error = [
37✔
450
            'code'    => '00000',
37✔
451
            'message' => '',
37✔
452
        ];
37✔
453

454
        $sqlsrvErrors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
37✔
455

456
        if (! is_array($sqlsrvErrors)) {
37✔
457
            return $error;
2✔
458
        }
459

460
        $sqlsrvError = array_shift($sqlsrvErrors);
35✔
461
        if (isset($sqlsrvError['SQLSTATE'])) {
35✔
462
            $error['code'] = isset($sqlsrvError['code']) ? $sqlsrvError['SQLSTATE'] . '/' . $sqlsrvError['code'] : $sqlsrvError['SQLSTATE'];
35✔
463
        } elseif (isset($sqlsrvError['code'])) {
×
464
            $error['code'] = $sqlsrvError['code'];
×
465
        }
466

467
        if (isset($sqlsrvError['message'])) {
35✔
468
            $error['message'] = $sqlsrvError['message'];
35✔
469
        }
470

471
        return $error;
35✔
472
    }
473

474
    /**
475
     * Returns the total number of rows affected by this query.
476
     */
477
    public function affectedRows(): int
478
    {
479
        if ($this->resultID === false) {
51✔
480
            return 0;
1✔
481
        }
482

483
        return sqlsrv_rows_affected($this->resultID);
50✔
484
    }
485

486
    /**
487
     * Select a specific database table to use.
488
     *
489
     * @return bool
490
     */
491
    public function setDatabase(?string $databaseName = null)
492
    {
493
        if ($databaseName === null || $databaseName === '') {
×
494
            $databaseName = $this->database;
×
495
        }
496

497
        if (empty($this->connID)) {
×
498
            $this->initialize();
×
499
        }
500

501
        if ($this->execute('USE ' . $this->_escapeString($databaseName))) {
×
502
            $this->database  = $databaseName;
×
503
            $this->dataCache = [];
×
504

505
            return true;
×
506
        }
507

508
        return false;
×
509
    }
510

511
    /**
512
     * Executes the query against the database.
513
     *
514
     * @return false|resource
515
     */
516
    protected function execute(string $sql)
517
    {
518
        $stmt = ($this->scrollable === false || $this->isWriteType($sql))
779✔
519
            ? sqlsrv_query($this->connID, $sql)
742✔
520
            : sqlsrv_query($this->connID, $sql, [], ['Scrollable' => $this->scrollable]);
778✔
521

522
        if ($stmt === false) {
779✔
523
            $trace   = debug_backtrace();
35✔
524
            $first   = array_shift($trace);
35✔
525
            $message = $this->getAllErrorMessages();
35✔
526

527
            log_message('error', "{message}\nin {exFile} on line {exLine}.\n{trace}", [
35✔
528
                'message' => $message,
35✔
529
                'exFile'  => clean_path($first['file']),
35✔
530
                'exLine'  => $first['line'],
35✔
531
                'trace'   => render_backtrace($trace),
35✔
532
            ]);
35✔
533

534
            $error     = $this->error();
35✔
535
            $exception = $this->isUniqueConstraintViolation()
35✔
536
                ? new UniqueConstraintViolationException($message, $error['code'])
3✔
537
                : new DatabaseException($message, $error['code']);
32✔
538

539
            if ($this->DBDebug) {
35✔
540
                throw $exception;
16✔
541
            }
542

543
            $this->lastException = $exception;
19✔
544
        }
545

546
        return $stmt;
779✔
547
    }
548

549
    private function isUniqueConstraintViolation(): bool
550
    {
551
        $errors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
35✔
552
        if (! is_array($errors)) {
35✔
NEW
553
            return false;
×
554
        }
555

556
        foreach ($errors as $error) {
35✔
557
            // SQLSTATE 23000 (integrity constraint violation) with SQL Server error
558
            // 2627 (UNIQUE CONSTRAINT or PRIMARY KEY violation) or 2601 (UNIQUE INDEX violation).
559
            if (($error['SQLSTATE'] ?? '') === '23000'
35✔
560
                && in_array($error['code'] ?? 0, [2627, 2601], true)) {
35✔
561
                return true;
3✔
562
            }
563
        }
564

565
        return false;
32✔
566
    }
567

568
    /**
569
     * Returns the last error encountered by this connection.
570
     *
571
     * @return array<string, int|string>
572
     *
573
     * @deprecated Use `error()` instead.
574
     */
575
    public function getError()
576
    {
577
        $error = [
×
578
            'code'    => '00000',
×
579
            'message' => '',
×
580
        ];
×
581

582
        $sqlsrvErrors = sqlsrv_errors(SQLSRV_ERR_ERRORS);
×
583

584
        if (! is_array($sqlsrvErrors)) {
×
585
            return $error;
×
586
        }
587

588
        $sqlsrvError = array_shift($sqlsrvErrors);
×
589
        if (isset($sqlsrvError['SQLSTATE'])) {
×
590
            $error['code'] = isset($sqlsrvError['code']) ? $sqlsrvError['SQLSTATE'] . '/' . $sqlsrvError['code'] : $sqlsrvError['SQLSTATE'];
×
591
        } elseif (isset($sqlsrvError['code'])) {
×
592
            $error['code'] = $sqlsrvError['code'];
×
593
        }
594

595
        if (isset($sqlsrvError['message'])) {
×
596
            $error['message'] = $sqlsrvError['message'];
×
597
        }
598

599
        return $error;
×
600
    }
601

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

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

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

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

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

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

644
        return parent::isWriteType($sql);
779✔
645
    }
646
}
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