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

codeigniter4 / CodeIgniter4 / 25410213656

06 May 2026 12:39AM UTC coverage: 88.287% (+0.04%) from 88.248%
25410213656

Pull #10150

github

web-flow
Merge 28612fe25 into 3cdb4c5e1
Pull Request #10150: feat: add Query Builder whereColumn methods

29 of 29 new or added lines in 1 file covered. (100.0%)

42 existing lines in 2 files now uncovered.

23516 of 26636 relevant lines covered (88.29%)

218.36 hits per line

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

90.66
/system/Database/BaseConnection.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;
15

16
use Closure;
17
use CodeIgniter\Database\Exceptions\DatabaseException;
18
use CodeIgniter\Events\Events;
19
use CodeIgniter\I18n\Time;
20
use Exception;
21
use ReflectionClass;
22
use ReflectionNamedType;
23
use ReflectionType;
24
use ReflectionUnionType;
25
use stdClass;
26
use Stringable;
27
use Throwable;
28

29
/**
30
 * @property-read array      $aliasedTables
31
 * @property-read string     $charset
32
 * @property-read bool       $compress
33
 * @property-read float      $connectDuration
34
 * @property-read float      $connectTime
35
 * @property-read string     $database
36
 * @property-read array      $dateFormat
37
 * @property-read string     $DBCollat
38
 * @property-read bool       $DBDebug
39
 * @property-read string     $DBDriver
40
 * @property-read string     $DBPrefix
41
 * @property-read string     $DSN
42
 * @property-read array|bool $encrypt
43
 * @property-read array      $failover
44
 * @property-read string     $hostname
45
 * @property-read Query      $lastQuery
46
 * @property-read string     $password
47
 * @property-read bool       $pConnect
48
 * @property-read int|string $port
49
 * @property-read bool       $pretend
50
 * @property-read string     $queryClass
51
 * @property-read array      $reservedIdentifiers
52
 * @property-read string     $subdriver
53
 * @property-read string     $swapPre
54
 * @property-read int        $transDepth
55
 * @property-read bool       $transFailure
56
 * @property-read bool       $transStatus
57
 * @property-read string     $username
58
 *
59
 * @template TConnection
60
 * @template TResult
61
 *
62
 * @implements ConnectionInterface<TConnection, TResult>
63
 * @see \CodeIgniter\Database\BaseConnectionTest
64
 */
65
abstract class BaseConnection implements ConnectionInterface
66
{
67
    /**
68
     * Cached builtin type names per class/property.
69
     *
70
     * @var array<class-string, array<string, list<string>>>
71
     */
72
    private static array $propertyBuiltinTypesCache = [];
73

74
    /**
75
     * Data Source Name / Connect string
76
     *
77
     * @var string
78
     */
79
    protected $DSN;
80

81
    /**
82
     * Database port
83
     *
84
     * @var int|string
85
     */
86
    protected $port = '';
87

88
    /**
89
     * Hostname
90
     *
91
     * @var string
92
     */
93
    protected $hostname;
94

95
    /**
96
     * Username
97
     *
98
     * @var string
99
     */
100
    protected $username;
101

102
    /**
103
     * Password
104
     *
105
     * @var string
106
     */
107
    protected $password;
108

109
    /**
110
     * Database name
111
     *
112
     * @var string
113
     */
114
    protected $database;
115

116
    /**
117
     * Database driver
118
     *
119
     * @var string
120
     */
121
    protected $DBDriver = 'MySQLi';
122

123
    /**
124
     * Sub-driver
125
     *
126
     * @used-by CI_DB_pdo_driver
127
     *
128
     * @var string
129
     */
130
    protected $subdriver;
131

132
    /**
133
     * Table prefix
134
     *
135
     * @var string
136
     */
137
    protected $DBPrefix = '';
138

139
    /**
140
     * Persistent connection flag
141
     *
142
     * @var bool
143
     */
144
    protected $pConnect = false;
145

146
    /**
147
     * Whether to throw Exception or not when an error occurs.
148
     *
149
     * @var bool
150
     */
151
    protected $DBDebug = true;
152

153
    /**
154
     * Character set
155
     *
156
     * This value must be updated by Config\Database if the driver use it.
157
     *
158
     * @var string
159
     */
160
    protected $charset = '';
161

162
    /**
163
     * Collation
164
     *
165
     * This value must be updated by Config\Database if the driver use it.
166
     *
167
     * @var string
168
     */
169
    protected $DBCollat = '';
170

171
    /**
172
     * Database session timezone
173
     *
174
     * false    = Don't set timezone (default, backward compatible)
175
     * true     = Automatically sync with app timezone
176
     * string   = Specific timezone (offset or named timezone)
177
     *
178
     * Named timezones (e.g., 'America/New_York') will be automatically
179
     * converted to offsets (e.g., '-05:00') for database compatibility.
180
     *
181
     * @var bool|string
182
     */
183
    protected $timezone = false;
184

185
    /**
186
     * Swap Prefix
187
     *
188
     * @var string
189
     */
190
    protected $swapPre = '';
191

192
    /**
193
     * Encryption flag/data
194
     *
195
     * @var array|bool
196
     */
197
    protected $encrypt = false;
198

199
    /**
200
     * Compression flag
201
     *
202
     * @var bool
203
     */
204
    protected $compress = false;
205

206
    /**
207
     * Settings for a failover connection.
208
     *
209
     * @var array
210
     */
211
    protected $failover = [];
212

213
    /**
214
     * The last query object that was executed
215
     * on this connection.
216
     *
217
     * @var Query
218
     */
219
    protected $lastQuery;
220

221
    /**
222
     * The exception that would have been thrown on the last failed query
223
     * if DBDebug were enabled. Null when the last query succeeded or when
224
     * DBDebug is true (in which case the exception is thrown directly and
225
     * this property is never set).
226
     */
227
    protected ?DatabaseException $lastException = null;
228

229
    /**
230
     * Connection ID
231
     *
232
     * @var false|TConnection
233
     */
234
    public $connID = false;
235

236
    /**
237
     * Result ID
238
     *
239
     * @var false|TResult
240
     */
241
    public $resultID = false;
242

243
    /**
244
     * Protect identifiers flag
245
     *
246
     * @var bool
247
     */
248
    public $protectIdentifiers = true;
249

250
    /**
251
     * List of reserved identifiers
252
     *
253
     * Identifiers that must NOT be escaped.
254
     *
255
     * @var array
256
     */
257
    protected $reservedIdentifiers = ['*'];
258

259
    /**
260
     * Identifier escape character
261
     *
262
     * @var array|string
263
     */
264
    public $escapeChar = '"';
265

266
    /**
267
     * ESCAPE statement string
268
     *
269
     * @var string
270
     */
271
    public $likeEscapeStr = " ESCAPE '%s' ";
272

273
    /**
274
     * ESCAPE character
275
     *
276
     * @var string
277
     */
278
    public $likeEscapeChar = '!';
279

280
    /**
281
     * RegExp used to escape identifiers
282
     *
283
     * @var array
284
     */
285
    protected $pregEscapeChar = [];
286

287
    /**
288
     * Holds previously looked up data
289
     * for performance reasons.
290
     *
291
     * @var array
292
     */
293
    public $dataCache = [];
294

295
    /**
296
     * Microtime when connection was made
297
     *
298
     * @var float
299
     */
300
    protected $connectTime = 0.0;
301

302
    /**
303
     * How long it took to establish connection.
304
     *
305
     * @var float
306
     */
307
    protected $connectDuration = 0.0;
308

309
    /**
310
     * If true, no queries will actually be
311
     * run against the database.
312
     *
313
     * @var bool
314
     */
315
    protected $pretend = false;
316

317
    /**
318
     * Transaction enabled flag
319
     *
320
     * @var bool
321
     */
322
    public $transEnabled = true;
323

324
    /**
325
     * Strict transaction mode flag
326
     *
327
     * @var bool
328
     */
329
    public $transStrict = true;
330

331
    /**
332
     * Transaction depth level
333
     *
334
     * @var int
335
     */
336
    protected $transDepth = 0;
337

338
    /**
339
     * Transaction status flag
340
     *
341
     * Used with transactions to determine if a rollback should occur.
342
     *
343
     * @var bool
344
     */
345
    protected $transStatus = true;
346

347
    /**
348
     * Transaction failure flag
349
     *
350
     * Used with transactions to determine if a transaction has failed.
351
     *
352
     * @var bool
353
     */
354
    protected $transFailure = false;
355

356
    /**
357
     * Whether to throw exceptions during transaction
358
     */
359
    protected bool $transException = false;
360

361
    /**
362
     * Callbacks to run after the outermost transaction commits.
363
     *
364
     * @var list<callable(): void>
365
     */
366
    protected array $transCommitCallbacks = [];
367

368
    /**
369
     * Callbacks to run after the outermost transaction rolls back.
370
     *
371
     * @var list<callable(): void>
372
     */
373
    protected array $transRollbackCallbacks = [];
374

375
    /**
376
     * Array of table aliases.
377
     *
378
     * @var list<string>
379
     */
380
    protected $aliasedTables = [];
381

382
    /**
383
     * Query Class
384
     *
385
     * @var string
386
     */
387
    protected $queryClass = Query::class;
388

389
    /**
390
     * Default Date/Time formats
391
     *
392
     * @var array<string, string>
393
     */
394
    protected array $dateFormat = [
395
        'date'        => 'Y-m-d',
396
        'datetime'    => 'Y-m-d H:i:s',
397
        'datetime-ms' => 'Y-m-d H:i:s.v',
398
        'datetime-us' => 'Y-m-d H:i:s.u',
399
        'time'        => 'H:i:s',
400
    ];
401

402
    /**
403
     * Saves our connection settings.
404
     */
405
    public function __construct(array $params)
406
    {
407
        if (isset($params['dateFormat'])) {
494✔
408
            $this->dateFormat = array_merge($this->dateFormat, $params['dateFormat']);
151✔
409
            unset($params['dateFormat']);
151✔
410
        }
411

412
        $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($params));
494✔
413

414
        foreach ($params as $key => $value) {
494✔
415
            if (property_exists($this, $key)) {
184✔
416
                $this->{$key} = $this->castScalarValueForTypedProperty(
184✔
417
                    $value,
184✔
418
                    $typedPropertyTypes[$key] ?? [],
184✔
419
                );
184✔
420
            }
421
        }
422

423
        $queryClass = str_replace('Connection', 'Query', static::class);
493✔
424

425
        if (class_exists($queryClass)) {
493✔
426
            $this->queryClass = $queryClass;
383✔
427
        }
428

429
        if ($this->failover !== []) {
493✔
430
            // If there is a failover database, connect now to do failover.
431
            // Otherwise, Query Builder creates SQL statement with the main database config
432
            // (DBPrefix) even when the main database is down.
433
            $this->initialize();
2✔
434
        }
435
    }
436

437
    /**
438
     * Some config values (especially env overrides without clear source type)
439
     * can still reach us as strings. Coerce them for typed properties to keep
440
     * strict typing compatible.
441
     *
442
     * @param list<string> $types
443
     */
444
    private function castScalarValueForTypedProperty(mixed $value, array $types): mixed
445
    {
446
        if (! is_string($value)) {
184✔
447
            return $value;
163✔
448
        }
449

450
        if ($types === [] || in_array('string', $types, true) || in_array('mixed', $types, true)) {
184✔
451
            return $value;
184✔
452
        }
453

454
        $trimmedValue = trim($value);
5✔
455

456
        if (in_array('null', $types, true) && strtolower($trimmedValue) === 'null') {
5✔
457
            return null;
1✔
458
        }
459

460
        if (in_array('int', $types, true) && preg_match('/^[+-]?\d+$/', $trimmedValue) === 1) {
5✔
461
            return (int) $trimmedValue;
2✔
462
        }
463

464
        if (in_array('float', $types, true) && is_numeric($trimmedValue)) {
4✔
465
            return (float) $trimmedValue;
×
466
        }
467

468
        if (in_array('bool', $types, true) || in_array('false', $types, true) || in_array('true', $types, true)) {
4✔
469
            $boolValue = filter_var($trimmedValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
3✔
470

471
            if ($boolValue !== null) {
3✔
472
                if (in_array('bool', $types, true)) {
3✔
473
                    return $boolValue;
2✔
474
                }
475

476
                if ($boolValue === false && in_array('false', $types, true)) {
1✔
477
                    return false;
1✔
478
                }
479

480
                if ($boolValue === true && in_array('true', $types, true)) {
1✔
481
                    return true;
1✔
482
                }
483
            }
484
        }
485

486
        return $value;
1✔
487
    }
488

489
    /**
490
     * @param list<string> $properties
491
     *
492
     * @return array<string, list<string>>
493
     */
494
    private function getBuiltinPropertyTypesMap(array $properties): array
495
    {
496
        $className = static::class;
494✔
497
        $requested = array_fill_keys($properties, true);
494✔
498

499
        if (! isset(self::$propertyBuiltinTypesCache[$className])) {
494✔
500
            self::$propertyBuiltinTypesCache[$className] = [];
28✔
501
        }
502

503
        // Fill only the properties requested by this call that are not cached yet.
504
        $missing = array_diff_key($requested, self::$propertyBuiltinTypesCache[$className]);
494✔
505

506
        if ($missing !== []) {
494✔
507
            $reflection = new ReflectionClass($className);
31✔
508

509
            foreach ($reflection->getProperties() as $property) {
31✔
510
                $propertyName = $property->getName();
31✔
511

512
                if (! isset($missing[$propertyName])) {
31✔
513
                    continue;
31✔
514
                }
515

516
                $type = $property->getType();
31✔
517

518
                if (! $type instanceof ReflectionType) {
31✔
519
                    self::$propertyBuiltinTypesCache[$className][$propertyName] = [];
30✔
520

521
                    continue;
30✔
522
                }
523

524
                $namedTypes   = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type];
18✔
525
                $builtinTypes = [];
18✔
526

527
                foreach ($namedTypes as $namedType) {
18✔
528
                    if (! $namedType instanceof ReflectionNamedType || ! $namedType->isBuiltin()) {
18✔
529
                        continue;
×
530
                    }
531

532
                    $builtinTypes[] = $namedType->getName();
18✔
533
                }
534

535
                if ($type->allowsNull() && ! in_array('null', $builtinTypes, true)) {
18✔
536
                    $builtinTypes[] = 'null';
13✔
537
                }
538

539
                self::$propertyBuiltinTypesCache[$className][$propertyName] = $builtinTypes;
18✔
540
            }
541

542
            // Untyped or unresolved properties are cached as empty to avoid re-reflecting them.
543
            foreach (array_keys($missing) as $propertyName) {
31✔
544
                self::$propertyBuiltinTypesCache[$className][$propertyName] ??= [];
31✔
545
            }
546
        }
547

548
        $typedProperties = [];
494✔
549

550
        foreach ($properties as $property) {
494✔
551
            $typedProperties[$property] = self::$propertyBuiltinTypesCache[$className][$property] ?? [];
184✔
552
        }
553

554
        return $typedProperties;
494✔
555
    }
556

557
    /**
558
     * Initializes the database connection/settings.
559
     *
560
     * @return void
561
     *
562
     * @throws DatabaseException
563
     */
564
    public function initialize()
565
    {
566
        /* If an established connection is available, then there's
567
         * no need to connect and select the database.
568
         *
569
         * Depending on the database driver, connID can be either
570
         * boolean TRUE, a resource or an object.
571
         */
572
        if ($this->connID) {
880✔
573
            return;
814✔
574
        }
575

576
        $this->connectTime = microtime(true);
82✔
577
        $connectionErrors  = [];
82✔
578

579
        try {
580
            // Connect to the database and set the connection ID
581
            $this->connID = $this->connect($this->pConnect);
82✔
582
        } catch (Throwable $e) {
2✔
583
            $this->connID       = false;
2✔
584
            $connectionErrors[] = sprintf(
2✔
585
                'Main connection [%s]: %s',
2✔
586
                $this->DBDriver,
2✔
587
                $e->getMessage(),
2✔
588
            );
2✔
589
            log_message('error', 'Error connecting to the database: ' . $e);
2✔
590
        }
591

592
        // No connection resource? Check if there is a failover else throw an error
593
        if (! $this->connID) {
82✔
594
            // Check if there is a failover set
595
            if (! empty($this->failover) && is_array($this->failover)) {
4✔
596
                // Go over all the failovers
597
                foreach ($this->failover as $index => $failover) {
2✔
598
                    $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($failover));
2✔
599

600
                    // Replace the current settings with those of the failover
601
                    foreach ($failover as $key => $val) {
2✔
602
                        if (property_exists($this, $key)) {
2✔
603
                            $this->{$key} = $this->castScalarValueForTypedProperty(
2✔
604
                                $val,
2✔
605
                                $typedPropertyTypes[$key] ?? [],
2✔
606
                            );
2✔
607
                        }
608
                    }
609

610
                    try {
611
                        // Try to connect
612
                        $this->connID = $this->connect($this->pConnect);
2✔
613
                    } catch (Throwable $e) {
1✔
614
                        $connectionErrors[] = sprintf(
1✔
615
                            'Failover #%d [%s]: %s',
1✔
616
                            ++$index,
1✔
617
                            $this->DBDriver,
1✔
618
                            $e->getMessage(),
1✔
619
                        );
1✔
620
                        log_message('error', 'Error connecting to the database: ' . $e);
1✔
621
                    }
622

623
                    // If a connection is made break the foreach loop
624
                    if ($this->connID) {
2✔
625
                        break;
2✔
626
                    }
627
                }
628
            }
629

630
            // We still don't have a connection?
631
            if (! $this->connID) {
4✔
632
                throw new DatabaseException(sprintf(
2✔
633
                    'Unable to connect to the database.%s%s',
2✔
634
                    PHP_EOL,
2✔
635
                    implode(PHP_EOL, $connectionErrors),
2✔
636
                ));
2✔
637
            }
638
        }
639

640
        $this->connectDuration = microtime(true) - $this->connectTime;
80✔
641
    }
642

643
    /**
644
     * Close the database connection.
645
     *
646
     * @return void
647
     */
648
    public function close()
649
    {
650
        if ($this->connID) {
3✔
651
            $this->_close();
3✔
652
            $this->connID = false;
3✔
653
        }
654
    }
655

656
    /**
657
     * Keep or establish the connection if no queries have been sent for
658
     * a length of time exceeding the server's idle timeout.
659
     *
660
     * @return void
661
     */
662
    public function reconnect()
663
    {
664
        if ($this->ping() === false) {
2✔
665
            $this->close();
1✔
666
            $this->initialize();
1✔
667
        }
668
    }
669

670
    /**
671
     * Platform dependent way method for closing the connection.
672
     *
673
     * @return void
674
     */
675
    abstract protected function _close();
676

677
    /**
678
     * Check if the connection is still alive.
679
     */
680
    public function ping(): bool
681
    {
682
        if ($this->connID === false) {
5✔
683
            return false;
2✔
684
        }
685

686
        return $this->_ping();
4✔
687
    }
688

689
    /**
690
     * Driver-specific ping implementation.
691
     */
692
    protected function _ping(): bool
693
    {
694
        try {
695
            $result = $this->simpleQuery('SELECT 1');
4✔
696

697
            return $result !== false;
4✔
698
        } catch (DatabaseException) {
×
699
            return false;
×
700
        }
701
    }
702

703
    /**
704
     * Create a persistent database connection.
705
     *
706
     * @return false|TConnection
707
     */
708
    public function persistentConnect()
709
    {
710
        return $this->connect(true);
×
711
    }
712

713
    /**
714
     * Returns the actual connection object. If both a 'read' and 'write'
715
     * connection has been specified, you can pass either term in to
716
     * get that connection. If you pass either alias in and only a single
717
     * connection is present, it must return the sole connection.
718
     *
719
     * @return false|TConnection
720
     */
721
    public function getConnection(?string $alias = null)
722
    {
723
        // @todo work with read/write connections
724
        return $this->connID;
2✔
725
    }
726

727
    /**
728
     * Returns the name of the current database being used.
729
     */
730
    public function getDatabase(): string
731
    {
732
        return empty($this->database) ? '' : $this->database;
821✔
733
    }
734

735
    /**
736
     * Set DB Prefix
737
     *
738
     * Set's the DB Prefix to something new without needing to reconnect
739
     *
740
     * @param string $prefix The prefix
741
     */
742
    public function setPrefix(string $prefix = ''): string
743
    {
744
        return $this->DBPrefix = $prefix;
13✔
745
    }
746

747
    /**
748
     * Returns the database prefix.
749
     */
750
    public function getPrefix(): string
751
    {
752
        return $this->DBPrefix;
12✔
753
    }
754

755
    /**
756
     * The name of the platform in use (MySQLi, Postgre, SQLite3, OCI8, etc)
757
     */
758
    public function getPlatform(): string
759
    {
760
        return $this->DBDriver;
23✔
761
    }
762

763
    /**
764
     * Sets the Table Aliases to use. These are typically
765
     * collected during use of the Builder, and set here
766
     * so queries are built correctly.
767
     *
768
     * @return $this
769
     */
770
    public function setAliasedTables(array $aliases)
771
    {
772
        $this->aliasedTables = $aliases;
1,059✔
773

774
        return $this;
1,059✔
775
    }
776

777
    /**
778
     * Add a table alias to our list.
779
     *
780
     * @return $this
781
     */
782
    public function addTableAlias(string $alias)
783
    {
784
        if ($alias === '') {
31✔
785
            return $this;
6✔
786
        }
787

788
        if (! in_array($alias, $this->aliasedTables, true)) {
25✔
789
            $this->aliasedTables[] = $alias;
25✔
790
        }
791

792
        return $this;
25✔
793
    }
794

795
    /**
796
     * Executes the query against the database.
797
     *
798
     * @return false|TResult
799
     */
800
    abstract protected function execute(string $sql);
801

802
    /**
803
     * Orchestrates a query against the database. Queries must use
804
     * Database\Statement objects to store the query and build it.
805
     * This method works with the cache.
806
     *
807
     * Should automatically handle different connections for read/write
808
     * queries if needed.
809
     *
810
     * @param array|string|null $binds
811
     *
812
     * @return BaseResult<TConnection, TResult>|bool|Query
813
     *
814
     * @todo BC set $queryClass default as null in 4.1
815
     */
816
    public function query(string $sql, $binds = null, bool $setEscapeFlags = true, string $queryClass = '')
817
    {
818
        $queryClass = $queryClass !== '' && $queryClass !== '0' ? $queryClass : $this->queryClass;
876✔
819

820
        if (empty($this->connID)) {
876✔
821
            $this->initialize();
53✔
822
        }
823

824
        /** @var Query $query */
825
        $query = new $queryClass($this);
876✔
826

827
        $query->setQuery($sql, $binds, $setEscapeFlags);
876✔
828

829
        if (! empty($this->swapPre) && ! empty($this->DBPrefix)) {
876✔
830
            $query->swapPrefix($this->DBPrefix, $this->swapPre);
×
831
        }
832

833
        $startTime = microtime(true);
876✔
834

835
        // Always save the last query so we can use
836
        // the getLastQuery() method.
837
        $this->lastQuery = $query;
876✔
838

839
        // If $pretend is true, then we just want to return
840
        // the actual query object here. There won't be
841
        // any results to return.
842
        if ($this->pretend) {
876✔
843
            $query->setDuration($startTime);
10✔
844

845
            return $query;
10✔
846
        }
847

848
        // Run the query for real
849
        try {
850
            $exception           = null;
876✔
851
            $this->lastException = null;
876✔
852
            $this->resultID      = $this->simpleQuery($query->getQuery());
876✔
853
        } catch (DatabaseException $exception) {
18✔
854
            $this->resultID = false;
18✔
855
        }
856

857
        if ($this->resultID === false) {
876✔
858
            $query->setDuration($startTime, $startTime);
41✔
859

860
            // This will trigger a rollback if transactions are being used
861
            $this->handleTransStatus();
41✔
862

863
            if (
864
                $this->DBDebug
41✔
865
                && (
866
                    // Not in transactions
867
                    $this->transDepth === 0
41✔
868
                    // In transactions, do not throw exception by default.
41✔
869
                    || $this->transException
41✔
870
                )
871
            ) {
872
                // We call this function in order to roll-back queries
873
                // if transactions are enabled. If we don't call this here
874
                // the error message will trigger an exit, causing the
875
                // transactions to remain in limbo.
876
                while ($this->transDepth !== 0) {
14✔
877
                    $transDepth = $this->transDepth;
3✔
878
                    $this->transComplete();
3✔
879

880
                    if ($transDepth === $this->transDepth) {
3✔
881
                        log_message('error', 'Database: Failure during an automated transaction commit/rollback!');
×
882
                        break;
×
883
                    }
884
                }
885

886
                // Let others do something with this query.
887
                Events::trigger('DBQuery', $query);
14✔
888

889
                if ($exception instanceof DatabaseException) {
14✔
890
                    throw $exception;
12✔
891
                }
892

893
                return false;
2✔
894
            }
895

896
            // Let others do something with this query.
897
            Events::trigger('DBQuery', $query);
27✔
898

899
            return false;
27✔
900
        }
901

902
        $query->setDuration($startTime);
876✔
903

904
        // Let others do something with this query
905
        Events::trigger('DBQuery', $query);
876✔
906

907
        // resultID is not false, so it must be successful
908
        if ($this->isWriteType($sql)) {
876✔
909
            return true;
837✔
910
        }
911

912
        // query is not write-type, so it must be read-type query; return QueryResult
913
        $resultClass = str_replace('Connection', 'Result', static::class);
875✔
914

915
        return new $resultClass($this->connID, $this->resultID);
875✔
916
    }
917

918
    /**
919
     * Performs a basic query against the database. No binding or caching
920
     * is performed, nor are transactions handled. Simply takes a raw
921
     * query string and returns the database-specific result id.
922
     *
923
     * @return false|TResult
924
     */
925
    public function simpleQuery(string $sql)
926
    {
927
        if (empty($this->connID)) {
883✔
928
            $this->initialize();
6✔
929
        }
930

931
        return $this->execute($sql);
883✔
932
    }
933

934
    /**
935
     * Disable Transactions
936
     *
937
     * This permits transactions to be disabled at run-time.
938
     *
939
     * @return void
940
     */
941
    public function transOff()
942
    {
943
        $this->transEnabled = false;
1✔
944
    }
945

946
    /**
947
     * Enable/disable Transaction Strict Mode
948
     *
949
     * When strict mode is enabled, if you are running multiple groups of
950
     * transactions, if one group fails all subsequent groups will be
951
     * rolled back.
952
     *
953
     * If strict mode is disabled, each group is treated autonomously,
954
     * meaning a failure of one group will not affect any others
955
     *
956
     * @param bool $mode = true
957
     *
958
     * @return $this
959
     */
960
    public function transStrict(bool $mode = true)
961
    {
962
        $this->transStrict = $mode;
6✔
963

964
        return $this;
6✔
965
    }
966

967
    /**
968
     * Start Transaction
969
     */
970
    public function transStart(bool $testMode = false): bool
971
    {
972
        if (! $this->transEnabled) {
63✔
973
            return false;
×
974
        }
975

976
        return $this->transBegin($testMode);
63✔
977
    }
978

979
    /**
980
     * If set to true, exceptions are thrown during transactions.
981
     *
982
     * @return $this
983
     */
984
    public function transException(bool $transException)
985
    {
986
        $this->transException = $transException;
4✔
987

988
        return $this;
4✔
989
    }
990

991
    /**
992
     * Complete Transaction
993
     */
994
    public function transComplete(): bool
995
    {
996
        if (! $this->transEnabled) {
67✔
997
            return false;
×
998
        }
999

1000
        // The query() function will set this flag to FALSE in the event that a query failed
1001
        if ($this->transStatus === false || $this->transFailure === true) {
67✔
1002
            try {
1003
                $this->transRollback();
23✔
1004
            } finally {
1005
                // If we are NOT running in strict mode, we will reset
1006
                // the _trans_status flag so that subsequent groups of
1007
                // transactions will be permitted.
1008
                if ($this->transStrict === false) {
23✔
1009
                    $this->transStatus = true;
23✔
1010
                }
1011
            }
1012

1013
            return false;
21✔
1014
        }
1015

1016
        return $this->transCommit();
49✔
1017
    }
1018

1019
    /**
1020
     * Lets you retrieve the transaction flag to determine if it has failed
1021
     */
1022
    public function transStatus(): bool
1023
    {
1024
        return $this->transStatus;
18✔
1025
    }
1026

1027
    /**
1028
     * Register a callback to run after the outermost transaction commits.
1029
     *
1030
     * If no transaction is active, the callback runs immediately.
1031
     *
1032
     * @param callable(): void $callback
1033
     *
1034
     * @return $this
1035
     */
1036
    public function afterCommit(callable $callback): static
1037
    {
1038
        if ($this->transDepth === 0) {
11✔
1039
            $callback();
2✔
1040

1041
            return $this;
2✔
1042
        }
1043

1044
        $this->transCommitCallbacks[] = $callback;
9✔
1045

1046
        return $this;
9✔
1047
    }
1048

1049
    /**
1050
     * Register a callback to run after the outermost transaction rolls back.
1051
     *
1052
     * If no transaction is active, the callback is not run.
1053
     *
1054
     * @param callable(): void $callback
1055
     *
1056
     * @return $this
1057
     */
1058
    public function afterRollback(callable $callback): static
1059
    {
1060
        if ($this->transDepth === 0) {
12✔
1061
            return $this;
1✔
1062
        }
1063

1064
        $this->transRollbackCallbacks[] = $callback;
11✔
1065

1066
        return $this;
11✔
1067
    }
1068

1069
    /**
1070
     * Run the callback inside a transaction.
1071
     *
1072
     * @template TReturn
1073
     *
1074
     * @param callable(self): TReturn $callback
1075
     *
1076
     * @return false|TReturn
1077
     */
1078
    public function transaction(callable $callback): mixed
1079
    {
1080
        if (! $this->transEnabled) {
14✔
1081
            return $callback($this);
1✔
1082
        }
1083

1084
        if (! $this->transBegin()) {
13✔
1085
            return false;
1✔
1086
        }
1087

1088
        try {
1089
            $result = $callback($this);
12✔
1090
        } catch (Throwable $e) {
5✔
1091
            try {
1092
                $this->transRollback();
5✔
1093
            } catch (Throwable $rollbackException) {
1✔
1094
                log_message('error', 'Database: Transaction callback threw an exception before rollback failed: ' . $e);
1✔
1095

1096
                throw $rollbackException;
1✔
1097
            } finally {
1098
                if ($this->transDepth > 0) {
5✔
1099
                    $this->transStatus = false;
1✔
1100
                } elseif ($this->transStrict === false) {
4✔
1101
                    $this->transStatus = true;
5✔
1102
                }
1103
            }
1104

1105
            throw $e;
4✔
1106
        }
1107

1108
        if (! $this->transComplete()) {
7✔
1109
            return false;
1✔
1110
        }
1111

1112
        return $result;
5✔
1113
    }
1114

1115
    /**
1116
     * Begin Transaction
1117
     */
1118
    public function transBegin(bool $testMode = false): bool
1119
    {
1120
        if (! $this->transEnabled) {
80✔
UNCOV
1121
            return false;
×
1122
        }
1123

1124
        // When transactions are nested we only begin/commit/rollback the outermost ones
1125
        if ($this->transDepth > 0) {
80✔
1126
            $this->transDepth++;
5✔
1127

1128
            return true;
5✔
1129
        }
1130

1131
        if (empty($this->connID)) {
80✔
1132
            $this->initialize();
3✔
1133
        }
1134

1135
        // Reset the transaction failure flag.
1136
        // If the $testMode flag is set to TRUE transactions will be rolled back
1137
        // even if the queries produce a successful result.
1138
        $this->transFailure = $testMode;
80✔
1139

1140
        if ($this->_transBegin()) {
80✔
1141
            $this->transDepth++;
79✔
1142

1143
            return true;
79✔
1144
        }
1145

1146
        return false;
1✔
1147
    }
1148

1149
    /**
1150
     * Commit Transaction
1151
     */
1152
    public function transCommit(): bool
1153
    {
1154
        if (! $this->transEnabled || $this->transDepth === 0) {
51✔
UNCOV
1155
            return false;
×
1156
        }
1157

1158
        // When transactions are nested we only begin/commit/rollback the outermost ones
1159
        if ($this->transDepth > 1 || $this->_transCommit()) {
51✔
1160
            $this->transDepth--;
51✔
1161

1162
            if ($this->transDepth === 0) {
51✔
1163
                $this->transRollbackCallbacks = [];
50✔
1164
                $this->runTransCommitCallbacks();
50✔
1165
            }
1166

1167
            return true;
49✔
1168
        }
1169

1170
        return false;
1✔
1171
    }
1172

1173
    /**
1174
     * Rollback Transaction
1175
     */
1176
    public function transRollback(): bool
1177
    {
1178
        if (! $this->transEnabled || $this->transDepth === 0) {
34✔
UNCOV
1179
            return false;
×
1180
        }
1181

1182
        // When transactions are nested we only begin/commit/rollback the outermost ones
1183
        if ($this->transDepth > 1 || $this->_transRollback()) {
34✔
1184
            $this->transDepth--;
34✔
1185

1186
            if ($this->transDepth === 0) {
34✔
1187
                $this->transCommitCallbacks = [];
34✔
1188
                $this->runTransRollbackCallbacks();
34✔
1189
            }
1190

1191
            return true;
30✔
1192
        }
1193

1194
        return false;
1✔
1195
    }
1196

1197
    /**
1198
     * Reset transaction status - to restart transactions after strict mode failure
1199
     */
1200
    public function resetTransStatus(): static
1201
    {
1202
        $this->transStatus = true;
5✔
1203

1204
        return $this;
5✔
1205
    }
1206

1207
    /**
1208
     * Handle transaction status when a query fails
1209
     *
1210
     * @internal This method is for internal database component use only
1211
     */
1212
    public function handleTransStatus(): void
1213
    {
1214
        if ($this->transDepth !== 0) {
45✔
1215
            $this->transStatus = false;
21✔
1216
        }
1217
    }
1218

1219
    /**
1220
     * Run and clear callbacks registered for a successful transaction commit.
1221
     */
1222
    protected function runTransCommitCallbacks(): void
1223
    {
1224
        $callbacks                  = $this->transCommitCallbacks;
50✔
1225
        $this->transCommitCallbacks = [];
50✔
1226

1227
        foreach ($callbacks as $callback) {
50✔
1228
            $callback();
8✔
1229
        }
1230
    }
1231

1232
    /**
1233
     * Run and clear callbacks registered for a transaction rollback.
1234
     */
1235
    protected function runTransRollbackCallbacks(): void
1236
    {
1237
        $callbacks                    = $this->transRollbackCallbacks;
34✔
1238
        $this->transRollbackCallbacks = [];
34✔
1239

1240
        foreach ($callbacks as $callback) {
34✔
1241
            $callback();
10✔
1242
        }
1243
    }
1244

1245
    /**
1246
     * Begin Transaction
1247
     */
1248
    abstract protected function _transBegin(): bool;
1249

1250
    /**
1251
     * Commit Transaction
1252
     */
1253
    abstract protected function _transCommit(): bool;
1254

1255
    /**
1256
     * Rollback Transaction
1257
     */
1258
    abstract protected function _transRollback(): bool;
1259

1260
    /**
1261
     * Returns a non-shared new instance of the query builder for this connection.
1262
     *
1263
     * @param array|string|TableName $tableName
1264
     *
1265
     * @return BaseBuilder
1266
     *
1267
     * @throws DatabaseException
1268
     */
1269
    public function table($tableName)
1270
    {
1271
        if (empty($tableName)) {
1,003✔
UNCOV
1272
            throw new DatabaseException('You must set the database table to be used with your query.');
×
1273
        }
1274

1275
        $className = str_replace('Connection', 'Builder', static::class);
1,003✔
1276

1277
        return new $className($tableName, $this);
1,003✔
1278
    }
1279

1280
    /**
1281
     * Returns a new instance of the BaseBuilder class with a cleared FROM clause.
1282
     */
1283
    public function newQuery(): BaseBuilder
1284
    {
1285
        // save table aliases
1286
        $tempAliases         = $this->aliasedTables;
14✔
1287
        $builder             = $this->table(',')->from([], true);
14✔
1288
        $this->aliasedTables = $tempAliases;
14✔
1289

1290
        return $builder;
14✔
1291
    }
1292

1293
    /**
1294
     * Creates a prepared statement with the database that can then
1295
     * be used to execute multiple statements against. Within the
1296
     * closure, you would build the query in any normal way, though
1297
     * the Query Builder is the expected manner.
1298
     *
1299
     * Example:
1300
     *    $stmt = $db->prepare(function($db)
1301
     *           {
1302
     *             return $db->table('users')
1303
     *                   ->where('id', 1)
1304
     *                     ->get();
1305
     *           })
1306
     *
1307
     * @param Closure(BaseConnection): mixed $func
1308
     *
1309
     * @return BasePreparedQuery|null
1310
     */
1311
    public function prepare(Closure $func, array $options = [])
1312
    {
1313
        if (empty($this->connID)) {
16✔
UNCOV
1314
            $this->initialize();
×
1315
        }
1316

1317
        $this->pretend();
16✔
1318

1319
        $sql = $func($this);
16✔
1320

1321
        $this->pretend(false);
16✔
1322

1323
        if ($sql instanceof QueryInterface) {
16✔
1324
            $sql = $sql->getOriginalQuery();
16✔
1325
        }
1326

1327
        $class = str_ireplace('Connection', 'PreparedQuery', static::class);
16✔
1328
        /** @var BasePreparedQuery $class */
1329
        $class = new $class($this);
16✔
1330

1331
        return $class->prepare($sql, $options);
16✔
1332
    }
1333

1334
    /**
1335
     * Returns the last query's statement object.
1336
     *
1337
     * @return Query
1338
     */
1339
    public function getLastQuery()
1340
    {
1341
        return $this->lastQuery;
11✔
1342
    }
1343

1344
    /**
1345
     * Returns a string representation of the last query's statement object.
1346
     */
1347
    public function showLastQuery(): string
1348
    {
UNCOV
1349
        return (string) $this->lastQuery;
×
1350
    }
1351

1352
    /**
1353
     * Returns the time we started to connect to this database in
1354
     * seconds with microseconds.
1355
     *
1356
     * Used by the Debug Toolbar's timeline.
1357
     */
1358
    public function getConnectStart(): ?float
1359
    {
1360
        return $this->connectTime;
1✔
1361
    }
1362

1363
    /**
1364
     * Returns the number of seconds with microseconds that it took
1365
     * to connect to the database.
1366
     *
1367
     * Used by the Debug Toolbar's timeline.
1368
     */
1369
    public function getConnectDuration(int $decimals = 6): string
1370
    {
1371
        return number_format($this->connectDuration, $decimals);
2✔
1372
    }
1373

1374
    /**
1375
     * Protect Identifiers
1376
     *
1377
     * This function is used extensively by the Query Builder class, and by
1378
     * a couple functions in this class.
1379
     * It takes a column or table name (optionally with an alias) and inserts
1380
     * the table prefix onto it. Some logic is necessary in order to deal with
1381
     * column names that include the path. Consider a query like this:
1382
     *
1383
     * SELECT hostname.database.table.column AS c FROM hostname.database.table
1384
     *
1385
     * Or a query with aliasing:
1386
     *
1387
     * SELECT m.member_id, m.member_name FROM members AS m
1388
     *
1389
     * Since the column name can include up to four segments (host, DB, table, column)
1390
     * or also have an alias prefix, we need to do a bit of work to figure this out and
1391
     * insert the table prefix (if it exists) in the proper position, and escape only
1392
     * the correct identifiers.
1393
     *
1394
     * @param array|int|string|TableName $item
1395
     * @param bool                       $prefixSingle       Prefix a table name with no segments?
1396
     * @param bool                       $protectIdentifiers Protect table or column names?
1397
     * @param bool                       $fieldExists        Supplied $item contains a column name?
1398
     *
1399
     * @return ($item is array ? array : string)
1400
     */
1401
    public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $protectIdentifiers = null, bool $fieldExists = true)
1402
    {
1403
        if (! is_bool($protectIdentifiers)) {
1,166✔
1404
            $protectIdentifiers = $this->protectIdentifiers;
1,133✔
1405
        }
1406

1407
        if (is_array($item)) {
1,166✔
1408
            $escapedArray = [];
1✔
1409

1410
            foreach ($item as $k => $v) {
1✔
1411
                $escapedArray[$this->protectIdentifiers($k)] = $this->protectIdentifiers($v, $prefixSingle, $protectIdentifiers, $fieldExists);
1✔
1412
            }
1413

1414
            return $escapedArray;
1✔
1415
        }
1416

1417
        if ($item instanceof TableName) {
1,166✔
1418
            /** @psalm-suppress NoValue I don't know why ERROR. */
1419
            return $this->escapeTableName($item);
2✔
1420
        }
1421

1422
        // If you pass `['column1', 'column2']`, `$item` will be int because the array keys are int.
1423
        $item = (string) $item;
1,166✔
1424

1425
        // This is basically a bug fix for queries that use MAX, MIN, etc.
1426
        // If a parenthesis is found we know that we do not need to
1427
        // escape the data or add a prefix. There's probably a more graceful
1428
        // way to deal with this, but I'm not thinking of it
1429
        //
1430
        // Added exception for single quotes as well, we don't want to alter
1431
        // literal strings.
1432
        if (strcspn($item, "()'") !== strlen($item)) {
1,166✔
1433
            /** @psalm-suppress NoValue I don't know why ERROR. */
1434
            return $item;
837✔
1435
        }
1436

1437
        // Do not protect identifiers and do not prefix, no swap prefix, there is nothing to do
1438
        if ($protectIdentifiers === false && $prefixSingle === false && $this->swapPre === '') {
1,155✔
1439
            /** @psalm-suppress NoValue I don't know why ERROR. */
1440
            return $item;
105✔
1441
        }
1442

1443
        // Convert tabs or multiple spaces into single spaces
1444
        /** @psalm-suppress NoValue I don't know why ERROR. */
1445
        $item = preg_replace('/\s+/', ' ', trim($item));
1,154✔
1446

1447
        // If the item has an alias declaration we remove it and set it aside.
1448
        // Note: strripos() is used in order to support spaces in table names
1449
        if ($offset = strripos($item, ' AS ')) {
1,154✔
1450
            $alias = ($protectIdentifiers) ? substr($item, $offset, 4) . $this->escapeIdentifiers(substr($item, $offset + 4)) : substr($item, $offset);
11✔
1451
            $item  = substr($item, 0, $offset);
11✔
1452
        } elseif ($offset = strrpos($item, ' ')) {
1,149✔
1453
            $alias = ($protectIdentifiers) ? ' ' . $this->escapeIdentifiers(substr($item, $offset + 1)) : substr($item, $offset);
13✔
1454
            $item  = substr($item, 0, $offset);
13✔
1455
        } else {
1456
            $alias = '';
1,143✔
1457
        }
1458

1459
        // Break the string apart if it contains periods, then insert the table prefix
1460
        // in the correct location, assuming the period doesn't indicate that we're dealing
1461
        // with an alias. While we're at it, we will escape the components
1462
        if (str_contains($item, '.')) {
1,154✔
1463
            return $this->protectDotItem($item, $alias, $protectIdentifiers, $fieldExists);
149✔
1464
        }
1465

1466
        // In some cases, especially 'from', we end up running through
1467
        // protect_identifiers twice. This algorithm won't work when
1468
        // it contains the escapeChar so strip it out.
1469
        $item = trim($item, $this->escapeChar);
1,146✔
1470

1471
        // Is there a table prefix? If not, no need to insert it
1472
        if ($this->DBPrefix !== '') {
1,146✔
1473
            // Verify table prefix and replace if necessary
1474
            if ($this->swapPre !== '' && str_starts_with($item, $this->swapPre)) {
878✔
UNCOV
1475
                $item = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $item);
×
1476
            }
1477
            // Do we prefix an item with no segments?
1478
            elseif ($prefixSingle && ! str_starts_with($item, $this->DBPrefix)) {
878✔
1479
                $item = $this->DBPrefix . $item;
871✔
1480
            }
1481
        }
1482

1483
        if ($protectIdentifiers === true && ! in_array($item, $this->reservedIdentifiers, true)) {
1,146✔
1484
            $item = $this->escapeIdentifiers($item);
1,144✔
1485
        }
1486

1487
        return $item . $alias;
1,146✔
1488
    }
1489

1490
    private function protectDotItem(string $item, string $alias, bool $protectIdentifiers, bool $fieldExists): string
1491
    {
1492
        $parts = explode('.', $item);
149✔
1493

1494
        // Does the first segment of the exploded item match
1495
        // one of the aliases previously identified? If so,
1496
        // we have nothing more to do other than escape the item
1497
        //
1498
        // NOTE: The ! empty() condition prevents this method
1499
        // from breaking when QB isn't enabled.
1500
        if (! empty($this->aliasedTables) && in_array($parts[0], $this->aliasedTables, true)) {
149✔
1501
            if ($protectIdentifiers) {
11✔
1502
                foreach ($parts as $key => $val) {
11✔
1503
                    if (! in_array($val, $this->reservedIdentifiers, true)) {
11✔
1504
                        $parts[$key] = $this->escapeIdentifiers($val);
11✔
1505
                    }
1506
                }
1507

1508
                $item = implode('.', $parts);
11✔
1509
            }
1510

1511
            return $item . $alias;
11✔
1512
        }
1513

1514
        // Is there a table prefix defined in the config file? If not, no need to do anything
1515
        if ($this->DBPrefix !== '') {
142✔
1516
            // We now add the table prefix based on some logic.
1517
            // Do we have 4 segments (hostname.database.table.column)?
1518
            // If so, we add the table prefix to the column name in the 3rd segment.
1519
            if (isset($parts[3])) {
134✔
UNCOV
1520
                $i = 2;
×
1521
            }
1522
            // Do we have 3 segments (database.table.column)?
1523
            // If so, we add the table prefix to the column name in 2nd position
1524
            elseif (isset($parts[2])) {
134✔
UNCOV
1525
                $i = 1;
×
1526
            }
1527
            // Do we have 2 segments (table.column)?
1528
            // If so, we add the table prefix to the column name in 1st segment
1529
            else {
1530
                $i = 0;
134✔
1531
            }
1532

1533
            // This flag is set when the supplied $item does not contain a field name.
1534
            // This can happen when this function is being called from a JOIN.
1535
            if ($fieldExists === false) {
134✔
UNCOV
1536
                $i++;
×
1537
            }
1538

1539
            // Verify table prefix and replace if necessary
1540
            if ($this->swapPre !== '' && str_starts_with($parts[$i], $this->swapPre)) {
134✔
UNCOV
1541
                $parts[$i] = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $parts[$i]);
×
1542
            }
1543
            // We only add the table prefix if it does not already exist
1544
            elseif (! str_starts_with($parts[$i], $this->DBPrefix)) {
134✔
1545
                $parts[$i] = $this->DBPrefix . $parts[$i];
134✔
1546
            }
1547

1548
            // Put the parts back together
1549
            $item = implode('.', $parts);
134✔
1550
        }
1551

1552
        if ($protectIdentifiers) {
142✔
1553
            $item = $this->escapeIdentifiers($item);
142✔
1554
        }
1555

1556
        return $item . $alias;
142✔
1557
    }
1558

1559
    /**
1560
     * Escape the SQL Identifier
1561
     *
1562
     * This function escapes single identifier.
1563
     *
1564
     * @param non-empty-string|TableName $item
1565
     */
1566
    public function escapeIdentifier($item): string
1567
    {
1568
        if ($item === '') {
769✔
UNCOV
1569
            return '';
×
1570
        }
1571

1572
        if ($item instanceof TableName) {
769✔
1573
            return $this->escapeTableName($item);
7✔
1574
        }
1575

1576
        return $this->escapeChar
769✔
1577
            . str_replace(
769✔
1578
                $this->escapeChar,
769✔
1579
                $this->escapeChar . $this->escapeChar,
769✔
1580
                $item,
769✔
1581
            )
769✔
1582
            . $this->escapeChar;
769✔
1583
    }
1584

1585
    /**
1586
     * Returns escaped table name with alias.
1587
     */
1588
    private function escapeTableName(TableName $tableName): string
1589
    {
1590
        $alias = $tableName->getAlias();
7✔
1591

1592
        return $this->escapeIdentifier($tableName->getActualTableName())
7✔
1593
            . (($alias !== '') ? ' ' . $this->escapeIdentifier($alias) : '');
7✔
1594
    }
1595

1596
    /**
1597
     * Escape the SQL Identifiers
1598
     *
1599
     * This function escapes column and table names
1600
     *
1601
     * @param array|string $item
1602
     *
1603
     * @return ($item is array ? array : string)
1604
     */
1605
    public function escapeIdentifiers($item)
1606
    {
1607
        if ($this->escapeChar === '' || empty($item) || in_array($item, $this->reservedIdentifiers, true)) {
1,172✔
1608
            return $item;
5✔
1609
        }
1610

1611
        if (is_array($item)) {
1,171✔
1612
            foreach ($item as $key => $value) {
782✔
1613
                $item[$key] = $this->escapeIdentifiers($value);
782✔
1614
            }
1615

1616
            return $item;
782✔
1617
        }
1618

1619
        // Avoid breaking functions and literal values inside queries
1620
        if (ctype_digit($item)
1,171✔
1621
            || $item[0] === "'"
1,170✔
1622
            || ($this->escapeChar !== '"' && $item[0] === '"')
1,170✔
1623
            || str_contains($item, '(')) {
1,171✔
1624
            return $item;
47✔
1625
        }
1626

1627
        if ($this->pregEscapeChar === []) {
1,170✔
1628
            if (is_array($this->escapeChar)) {
345✔
UNCOV
1629
                $this->pregEscapeChar = [
×
UNCOV
1630
                    preg_quote($this->escapeChar[0], '/'),
×
UNCOV
1631
                    preg_quote($this->escapeChar[1], '/'),
×
UNCOV
1632
                    $this->escapeChar[0],
×
UNCOV
1633
                    $this->escapeChar[1],
×
UNCOV
1634
                ];
×
1635
            } else {
1636
                $this->pregEscapeChar[0] = $this->pregEscapeChar[1] = preg_quote($this->escapeChar, '/');
345✔
1637
                $this->pregEscapeChar[2] = $this->pregEscapeChar[3] = $this->escapeChar;
345✔
1638
            }
1639
        }
1640

1641
        foreach ($this->reservedIdentifiers as $id) {
1,170✔
1642
            /** @psalm-suppress NoValue I don't know why ERROR. */
1643
            if (str_contains($item, '.' . $id)) {
1,170✔
1644
                return preg_replace(
3✔
1645
                    '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?\./i',
3✔
1646
                    $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '.',
3✔
1647
                    $item,
3✔
1648
                );
3✔
1649
            }
1650
        }
1651

1652
        /** @psalm-suppress NoValue I don't know why ERROR. */
1653
        return preg_replace(
1,168✔
1654
            '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?(\.)?/i',
1,168✔
1655
            $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '$2',
1,168✔
1656
            $item,
1,168✔
1657
        );
1,168✔
1658
    }
1659

1660
    /**
1661
     * Prepends a database prefix if one exists in configuration
1662
     *
1663
     * @throws DatabaseException
1664
     */
1665
    public function prefixTable(string $table = ''): string
1666
    {
1667
        if ($table === '') {
3✔
UNCOV
1668
            throw new DatabaseException('A table name is required for that operation.');
×
1669
        }
1670

1671
        return $this->DBPrefix . $table;
3✔
1672
    }
1673

1674
    /**
1675
     * Returns the total number of rows affected by this query.
1676
     */
1677
    abstract public function affectedRows(): int;
1678

1679
    /**
1680
     * "Smart" Escape String
1681
     *
1682
     * Escapes data based on type.
1683
     * Sets boolean and null types
1684
     *
1685
     * @param array|bool|float|int|object|string|null $str
1686
     *
1687
     * @return ($str is array ? array : float|int|string)
1688
     */
1689
    public function escape($str)
1690
    {
1691
        if (is_array($str)) {
973✔
1692
            return array_map($this->escape(...), $str);
798✔
1693
        }
1694

1695
        if ($str instanceof Stringable) {
973✔
1696
            if ($str instanceof RawSql) {
13✔
1697
                return $str->__toString();
12✔
1698
            }
1699

1700
            $str = (string) $str;
1✔
1701
        }
1702

1703
        if (is_string($str)) {
970✔
1704
            return "'" . $this->escapeString($str) . "'";
917✔
1705
        }
1706

1707
        if (is_bool($str)) {
891✔
1708
            return ($str === false) ? 0 : 1;
8✔
1709
        }
1710

1711
        return $str ?? 'NULL';
889✔
1712
    }
1713

1714
    /**
1715
     * Escape String
1716
     *
1717
     * @param list<string|Stringable>|string|Stringable $str  Input string
1718
     * @param bool                                      $like Whether the string will be used in a LIKE condition
1719
     *
1720
     * @return list<string>|string
1721
     */
1722
    public function escapeString($str, bool $like = false)
1723
    {
1724
        if (is_array($str)) {
917✔
UNCOV
1725
            foreach ($str as $key => $val) {
×
UNCOV
1726
                $str[$key] = $this->escapeString($val, $like);
×
1727
            }
1728

UNCOV
1729
            return $str;
×
1730
        }
1731

1732
        if ($str instanceof Stringable) {
917✔
1733
            if ($str instanceof RawSql) {
2✔
UNCOV
1734
                return $str->__toString();
×
1735
            }
1736

1737
            $str = (string) $str;
2✔
1738
        }
1739

1740
        $str = $this->_escapeString($str);
917✔
1741

1742
        // escape LIKE condition wildcards
1743
        if ($like) {
917✔
1744
            return str_replace(
2✔
1745
                [
2✔
1746
                    $this->likeEscapeChar,
2✔
1747
                    '%',
2✔
1748
                    '_',
2✔
1749
                ],
2✔
1750
                [
2✔
1751
                    $this->likeEscapeChar . $this->likeEscapeChar,
2✔
1752
                    $this->likeEscapeChar . '%',
2✔
1753
                    $this->likeEscapeChar . '_',
2✔
1754
                ],
2✔
1755
                $str,
2✔
1756
            );
2✔
1757
        }
1758

1759
        return $str;
917✔
1760
    }
1761

1762
    /**
1763
     * Escape LIKE String
1764
     *
1765
     * Calls the individual driver for platform
1766
     * specific escaping for LIKE conditions
1767
     *
1768
     * @param list<string|Stringable>|string|Stringable $str
1769
     *
1770
     * @return list<string>|string
1771
     */
1772
    public function escapeLikeString($str)
1773
    {
1774
        return $this->escapeString($str, true);
2✔
1775
    }
1776

1777
    /**
1778
     * Platform independent string escape.
1779
     *
1780
     * Will likely be overridden in child classes.
1781
     */
1782
    protected function _escapeString(string $str): string
1783
    {
1784
        return str_replace("'", "''", remove_invisible_characters($str, false));
873✔
1785
    }
1786

1787
    /**
1788
     * This function enables you to call PHP database functions that are not natively included
1789
     * in CodeIgniter, in a platform independent manner.
1790
     *
1791
     * @param array ...$params
1792
     *
1793
     * @throws DatabaseException
1794
     */
1795
    public function callFunction(string $functionName, ...$params): bool
1796
    {
1797
        $driver = $this->getDriverFunctionPrefix();
2✔
1798

1799
        if (! str_starts_with($functionName, $driver)) {
2✔
1800
            $functionName = $driver . $functionName;
1✔
1801
        }
1802

1803
        if (! function_exists($functionName)) {
2✔
UNCOV
1804
            if ($this->DBDebug) {
×
UNCOV
1805
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1806
            }
1807

UNCOV
1808
            return false;
×
1809
        }
1810

1811
        return $functionName(...$params);
2✔
1812
    }
1813

1814
    /**
1815
     * Get the prefix of the function to access the DB.
1816
     */
1817
    protected function getDriverFunctionPrefix(): string
1818
    {
UNCOV
1819
        return strtolower($this->DBDriver) . '_';
×
1820
    }
1821

1822
    // --------------------------------------------------------------------
1823
    // META Methods
1824
    // --------------------------------------------------------------------
1825

1826
    /**
1827
     * Returns an array of table names
1828
     *
1829
     * @return false|list<string>
1830
     *
1831
     * @throws DatabaseException
1832
     */
1833
    public function listTables(bool $constrainByPrefix = false)
1834
    {
1835
        if (isset($this->dataCache['table_names']) && $this->dataCache['table_names']) {
830✔
1836
            return $constrainByPrefix
824✔
1837
                ? preg_grep("/^{$this->DBPrefix}/", $this->dataCache['table_names'])
2✔
1838
                : $this->dataCache['table_names'];
824✔
1839
        }
1840

1841
        $sql = $this->_listTables($constrainByPrefix);
84✔
1842

1843
        if ($sql === false) {
84✔
UNCOV
1844
            if ($this->DBDebug) {
×
UNCOV
1845
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1846
            }
1847

UNCOV
1848
            return false;
×
1849
        }
1850

1851
        $this->dataCache['table_names'] = [];
84✔
1852

1853
        $query = $this->query($sql);
84✔
1854

1855
        foreach ($query->getResultArray() as $row) {
84✔
1856
            /** @var string $table */
1857
            $table = $row['table_name'] ?? $row['TABLE_NAME'] ?? $row[array_key_first($row)];
81✔
1858

1859
            $this->dataCache['table_names'][] = $table;
81✔
1860
        }
1861

1862
        return $this->dataCache['table_names'];
84✔
1863
    }
1864

1865
    /**
1866
     * Determine if a particular table exists
1867
     *
1868
     * @param bool $cached Whether to use data cache
1869
     */
1870
    public function tableExists(string $tableName, bool $cached = true): bool
1871
    {
1872
        if ($cached) {
824✔
1873
            return in_array($this->protectIdentifiers($tableName, true, false, false), $this->listTables(), true);
823✔
1874
        }
1875

1876
        if (false === ($sql = $this->_listTables(false, $tableName))) {
777✔
1877
            if ($this->DBDebug) {
×
UNCOV
1878
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1879
            }
1880

1881
            return false;
×
1882
        }
1883

1884
        $tableExists = $this->query($sql)->getResultArray() !== [];
777✔
1885

1886
        // if cache has been built already
1887
        if (! empty($this->dataCache['table_names'])) {
777✔
1888
            $key = array_search(
773✔
1889
                strtolower($tableName),
773✔
1890
                array_map(strtolower(...), $this->dataCache['table_names']),
773✔
1891
                true,
773✔
1892
            );
773✔
1893

1894
            // table doesn't exist but still in cache - lets reset cache, it can be rebuilt later
1895
            // OR if table does exist but is not found in cache
1896
            if (($key !== false && ! $tableExists) || ($key === false && $tableExists)) {
773✔
1897
                $this->resetDataCache();
1✔
1898
            }
1899
        }
1900

1901
        return $tableExists;
777✔
1902
    }
1903

1904
    /**
1905
     * Fetch Field Names
1906
     *
1907
     * @param string|TableName $tableName
1908
     *
1909
     * @return false|list<string>
1910
     *
1911
     * @throws DatabaseException
1912
     */
1913
    public function getFieldNames($tableName)
1914
    {
1915
        $table = ($tableName instanceof TableName) ? $tableName->getTableName() : $tableName;
12✔
1916

1917
        // Is there a cached result?
1918
        if (isset($this->dataCache['field_names'][$table])) {
12✔
1919
            return $this->dataCache['field_names'][$table];
7✔
1920
        }
1921

1922
        if (empty($this->connID)) {
8✔
UNCOV
1923
            $this->initialize();
×
1924
        }
1925

1926
        if (false === ($sql = $this->_listColumns($tableName))) {
8✔
UNCOV
1927
            if ($this->DBDebug) {
×
UNCOV
1928
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1929
            }
1930

UNCOV
1931
            return false;
×
1932
        }
1933

1934
        $query = $this->query($sql);
8✔
1935

1936
        $this->dataCache['field_names'][$table] = [];
8✔
1937

1938
        foreach ($query->getResultArray() as $row) {
8✔
1939
            // Do we know from where to get the column's name?
1940
            if (! isset($key)) {
8✔
1941
                if (isset($row['column_name'])) {
8✔
1942
                    $key = 'column_name';
8✔
1943
                } elseif (isset($row['COLUMN_NAME'])) {
8✔
1944
                    $key = 'COLUMN_NAME';
8✔
1945
                } else {
1946
                    // We have no other choice but to just get the first element's key.
1947
                    $key = key($row);
8✔
1948
                }
1949
            }
1950

1951
            $this->dataCache['field_names'][$table][] = $row[$key];
8✔
1952
        }
1953

1954
        return $this->dataCache['field_names'][$table];
8✔
1955
    }
1956

1957
    /**
1958
     * Determine if a particular field exists
1959
     */
1960
    public function fieldExists(string $fieldName, string $tableName): bool
1961
    {
1962
        return in_array($fieldName, $this->getFieldNames($tableName), true);
8✔
1963
    }
1964

1965
    /**
1966
     * Returns an object with field data
1967
     *
1968
     * @return list<stdClass>
1969
     */
1970
    public function getFieldData(string $table)
1971
    {
1972
        return $this->_fieldData($this->protectIdentifiers($table, true, false, false));
150✔
1973
    }
1974

1975
    /**
1976
     * Returns an object with key data
1977
     *
1978
     * @return array<string, stdClass>
1979
     */
1980
    public function getIndexData(string $table)
1981
    {
1982
        return $this->_indexData($this->protectIdentifiers($table, true, false, false));
165✔
1983
    }
1984

1985
    /**
1986
     * Returns an object with foreign key data
1987
     *
1988
     * @return array<string, stdClass>
1989
     */
1990
    public function getForeignKeyData(string $table)
1991
    {
1992
        return $this->_foreignKeyData($this->protectIdentifiers($table, true, false, false));
37✔
1993
    }
1994

1995
    /**
1996
     * Converts array of arrays generated by _foreignKeyData() to array of objects
1997
     *
1998
     * @return array<string, stdClass>
1999
     *
2000
     * array[
2001
     *    {constraint_name} =>
2002
     *        stdClass[
2003
     *            'constraint_name'     => string,
2004
     *            'table_name'          => string,
2005
     *            'column_name'         => string[],
2006
     *            'foreign_table_name'  => string,
2007
     *            'foreign_column_name' => string[],
2008
     *            'on_delete'           => string,
2009
     *            'on_update'           => string,
2010
     *            'match'               => string
2011
     *        ]
2012
     * ]
2013
     */
2014
    protected function foreignKeyDataToObjects(array $data)
2015
    {
2016
        $retVal = [];
37✔
2017

2018
        foreach ($data as $row) {
37✔
2019
            $name = $row['constraint_name'];
12✔
2020

2021
            // for sqlite generate name
2022
            if ($name === null) {
12✔
2023
                $name = $row['table_name'] . '_' . implode('_', $row['column_name']) . '_foreign';
11✔
2024
            }
2025

2026
            $obj                      = new stdClass();
12✔
2027
            $obj->constraint_name     = $name;
12✔
2028
            $obj->table_name          = $row['table_name'];
12✔
2029
            $obj->column_name         = $row['column_name'];
12✔
2030
            $obj->foreign_table_name  = $row['foreign_table_name'];
12✔
2031
            $obj->foreign_column_name = $row['foreign_column_name'];
12✔
2032
            $obj->on_delete           = $row['on_delete'];
12✔
2033
            $obj->on_update           = $row['on_update'];
12✔
2034
            $obj->match               = $row['match'];
12✔
2035

2036
            $retVal[$name] = $obj;
12✔
2037
        }
2038

2039
        return $retVal;
37✔
2040
    }
2041

2042
    /**
2043
     * Disables foreign key checks temporarily.
2044
     *
2045
     * @return bool
2046
     */
2047
    public function disableForeignKeyChecks()
2048
    {
2049
        $sql = $this->_disableForeignKeyChecks();
791✔
2050

2051
        if ($sql === '') {
791✔
2052
            // The feature is not supported.
UNCOV
2053
            return false;
×
2054
        }
2055

2056
        return $this->query($sql);
791✔
2057
    }
2058

2059
    /**
2060
     * Enables foreign key checks temporarily.
2061
     *
2062
     * @return bool
2063
     */
2064
    public function enableForeignKeyChecks()
2065
    {
2066
        $sql = $this->_enableForeignKeyChecks();
874✔
2067

2068
        if ($sql === '') {
874✔
2069
            // The feature is not supported.
UNCOV
2070
            return false;
×
2071
        }
2072

2073
        return $this->query($sql);
874✔
2074
    }
2075

2076
    /**
2077
     * Allows the engine to be set into a mode where queries are not
2078
     * actually executed, but they are still generated, timed, etc.
2079
     *
2080
     * This is primarily used by the prepared query functionality.
2081
     *
2082
     * @return $this
2083
     */
2084
    public function pretend(bool $pretend = true)
2085
    {
2086
        $this->pretend = $pretend;
17✔
2087

2088
        return $this;
17✔
2089
    }
2090

2091
    /**
2092
     * Empties our data cache. Especially helpful during testing.
2093
     *
2094
     * @return $this
2095
     */
2096
    public function resetDataCache()
2097
    {
2098
        $this->dataCache = [];
36✔
2099

2100
        return $this;
36✔
2101
    }
2102

2103
    /**
2104
     * Determines if the statement is a write-type query or not.
2105
     *
2106
     * @param string $sql
2107
     */
2108
    public function isWriteType($sql): bool
2109
    {
2110
        return (bool) preg_match('/^\s*(WITH\s.+(\s|[)]))?"?(SET|INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|TRUNCATE|LOAD|COPY|ALTER|RENAME|GRANT|REVOKE|LOCK|UNLOCK|REINDEX|MERGE)\s(?!.*\sRETURNING\s)/is', $sql);
900✔
2111
    }
2112

2113
    /**
2114
     * Returns the last error code and message.
2115
     *
2116
     * Must return an array with keys 'code' and 'message':
2117
     *
2118
     * @return array{code: int|string|null, message: string|null}
2119
     */
2120
    abstract public function error(): array;
2121

2122
    /**
2123
     * Returns the exception that would have been thrown on the last failed
2124
     * query if DBDebug were enabled. Returns null if the last query succeeded
2125
     * or if DBDebug is true (in which case the exception is always thrown
2126
     * directly and this method will always return null).
2127
     */
2128
    public function getLastException(): ?DatabaseException
2129
    {
2130
        return $this->lastException;
4✔
2131
    }
2132

2133
    /**
2134
     * Insert ID
2135
     *
2136
     * @return int|string
2137
     */
2138
    abstract public function insertID();
2139

2140
    /**
2141
     * Generates the SQL for listing tables in a platform-dependent manner.
2142
     *
2143
     * @param string|null $tableName If $tableName is provided will return only this table if exists.
2144
     *
2145
     * @return false|string
2146
     */
2147
    abstract protected function _listTables(bool $constrainByPrefix = false, ?string $tableName = null);
2148

2149
    /**
2150
     * Generates a platform-specific query string so that the column names can be fetched.
2151
     *
2152
     * @param string|TableName $table
2153
     *
2154
     * @return false|string
2155
     */
2156
    abstract protected function _listColumns($table = '');
2157

2158
    /**
2159
     * Platform-specific field data information.
2160
     *
2161
     * @see getFieldData()
2162
     *
2163
     * @return list<stdClass>
2164
     */
2165
    abstract protected function _fieldData(string $table): array;
2166

2167
    /**
2168
     * Platform-specific index data.
2169
     *
2170
     * @see    getIndexData()
2171
     *
2172
     * @return array<string, stdClass>
2173
     */
2174
    abstract protected function _indexData(string $table): array;
2175

2176
    /**
2177
     * Platform-specific foreign keys data.
2178
     *
2179
     * @see    getForeignKeyData()
2180
     *
2181
     * @return array<string, stdClass>
2182
     */
2183
    abstract protected function _foreignKeyData(string $table): array;
2184

2185
    /**
2186
     * Platform-specific SQL statement to disable foreign key checks.
2187
     *
2188
     * If this feature is not supported, return empty string.
2189
     *
2190
     * @TODO This method should be moved to an interface that represents foreign key support.
2191
     *
2192
     * @return string
2193
     *
2194
     * @see disableForeignKeyChecks()
2195
     */
2196
    protected function _disableForeignKeyChecks()
2197
    {
UNCOV
2198
        return '';
×
2199
    }
2200

2201
    /**
2202
     * Platform-specific SQL statement to enable foreign key checks.
2203
     *
2204
     * If this feature is not supported, return empty string.
2205
     *
2206
     * @TODO This method should be moved to an interface that represents foreign key support.
2207
     *
2208
     * @return string
2209
     *
2210
     * @see enableForeignKeyChecks()
2211
     */
2212
    protected function _enableForeignKeyChecks()
2213
    {
UNCOV
2214
        return '';
×
2215
    }
2216

2217
    /**
2218
     * Converts a named timezone to an offset string.
2219
     *
2220
     * Converts timezone identifiers (e.g., 'America/New_York') to offset strings
2221
     * (e.g., '-05:00' or '-04:00' depending on DST). This is useful because not all
2222
     * databases have timezone tables loaded, but all support offset notation.
2223
     *
2224
     * @param string $timezone Named timezone (e.g., 'America/New_York', 'UTC', 'Europe/Paris')
2225
     *
2226
     * @return string Offset string (e.g., '+00:00', '-05:00', '+01:00')
2227
     */
2228
    protected function convertTimezoneToOffset(string $timezone): string
2229
    {
2230
        // If it's already an offset, return as-is
2231
        if (preg_match('/^[+-]\d{2}:\d{2}$/', $timezone)) {
9✔
2232
            return $timezone;
3✔
2233
        }
2234

2235
        try {
2236
            $offset = Time::now($timezone)->getOffset();
6✔
2237

2238
            // Convert offset seconds to +-HH:MM format
2239
            $hours   = (int) ($offset / 3600);
5✔
2240
            $minutes = abs((int) (($offset % 3600) / 60));
5✔
2241

2242
            return sprintf('%+03d:%02d', $hours, $minutes);
5✔
2243
        } catch (Exception $e) {
1✔
2244
            // If timezone conversion fails, log and return UTC
2245
            log_message('error', "Invalid timezone '{$timezone}'. Falling back to UTC. {$e->getMessage()}.");
1✔
2246

2247
            return '+00:00';
1✔
2248
        }
2249
    }
2250

2251
    /**
2252
     * Gets the timezone string to use for database session.
2253
     *
2254
     * Handles the timezone configuration logic:
2255
     * - false: Don't set timezone (returns null)
2256
     * - true: Auto-sync with app timezone from config
2257
     * - string: Use specific timezone (converts named timezones to offsets)
2258
     *
2259
     * @return string|null The timezone offset string, or null if timezone should not be set
2260
     */
2261
    protected function getSessionTimezone(): ?string
2262
    {
2263
        if ($this->timezone === false) {
68✔
2264
            return null;
62✔
2265
        }
2266

2267
        // Auto-sync with app timezone
2268
        if ($this->timezone === true) {
6✔
2269
            $appConfig = config('App');
2✔
2270
            $timezone  = $appConfig->appTimezone;
2✔
2271
        } else {
2272
            // Use specific timezone from config
2273
            $timezone = $this->timezone;
4✔
2274
        }
2275

2276
        return $this->convertTimezoneToOffset($timezone);
6✔
2277
    }
2278

2279
    /**
2280
     * Accessor for properties if they exist.
2281
     *
2282
     * @return array|bool|float|int|object|resource|string|null
2283
     */
2284
    public function __get(string $key)
2285
    {
2286
        if (property_exists($this, $key)) {
1,143✔
2287
            return $this->{$key};
1,142✔
2288
        }
2289

2290
        return null;
1✔
2291
    }
2292

2293
    /**
2294
     * Checker for properties existence.
2295
     */
2296
    public function __isset(string $key): bool
2297
    {
2298
        return property_exists($this, $key);
265✔
2299
    }
2300
}
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