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

codeigniter4 / CodeIgniter4 / 26659134712

29 May 2026 07:57PM UTC coverage: 88.502% (+0.02%) from 88.486%
26659134712

Pull #10244

github

web-flow
Merge 60fad714f into 800a72b54
Pull Request #10244: feat: add scoped transaction options

35 of 36 new or added lines in 1 file covered. (97.22%)

22 existing lines in 2 files now uncovered.

24245 of 27395 relevant lines covered (88.5%)

222.7 hits per line

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

91.39
/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\Database\Exceptions\RetryableTransactionException;
19
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
20
use CodeIgniter\Events\Events;
21
use CodeIgniter\Exceptions\InvalidArgumentException;
22
use CodeIgniter\I18n\Time;
23
use Exception;
24
use ReflectionClass;
25
use ReflectionNamedType;
26
use ReflectionType;
27
use ReflectionUnionType;
28
use stdClass;
29
use Stringable;
30
use Throwable;
31

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

77
    /**
78
     * Data Source Name / Connect string
79
     *
80
     * @var string
81
     */
82
    protected $DSN;
83

84
    /**
85
     * Database port
86
     *
87
     * @var int|string
88
     */
89
    protected $port = '';
90

91
    /**
92
     * Hostname
93
     *
94
     * @var string
95
     */
96
    protected $hostname;
97

98
    /**
99
     * Username
100
     *
101
     * @var string
102
     */
103
    protected $username;
104

105
    /**
106
     * Password
107
     *
108
     * @var string
109
     */
110
    protected $password;
111

112
    /**
113
     * Database name
114
     *
115
     * @var string
116
     */
117
    protected $database;
118

119
    /**
120
     * Database driver
121
     *
122
     * @var string
123
     */
124
    protected $DBDriver = 'MySQLi';
125

126
    /**
127
     * Sub-driver
128
     *
129
     * @used-by CI_DB_pdo_driver
130
     *
131
     * @var string
132
     */
133
    protected $subdriver;
134

135
    /**
136
     * Table prefix
137
     *
138
     * @var string
139
     */
140
    protected $DBPrefix = '';
141

142
    /**
143
     * Persistent connection flag
144
     *
145
     * @var bool
146
     */
147
    protected $pConnect = false;
148

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

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

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

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

188
    /**
189
     * Swap Prefix
190
     *
191
     * @var string
192
     */
193
    protected $swapPre = '';
194

195
    /**
196
     * Encryption flag/data
197
     *
198
     * @var array|bool
199
     */
200
    protected $encrypt = false;
201

202
    /**
203
     * Compression flag
204
     *
205
     * @var bool
206
     */
207
    protected $compress = false;
208

209
    /**
210
     * Settings for a failover connection.
211
     *
212
     * @var array
213
     */
214
    protected $failover = [];
215

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

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

232
    /**
233
     * The first database exception that caused the current transaction to fail.
234
     */
235
    protected ?DatabaseException $transFailureException = null;
236

237
    /**
238
     * Connection ID
239
     *
240
     * @var false|TConnection
241
     */
242
    public $connID = false;
243

244
    /**
245
     * Result ID
246
     *
247
     * @var false|TResult
248
     */
249
    public $resultID = false;
250

251
    /**
252
     * Protect identifiers flag
253
     *
254
     * @var bool
255
     */
256
    public $protectIdentifiers = true;
257

258
    /**
259
     * List of reserved identifiers
260
     *
261
     * Identifiers that must NOT be escaped.
262
     *
263
     * @var array
264
     */
265
    protected $reservedIdentifiers = ['*'];
266

267
    /**
268
     * Identifier escape character
269
     *
270
     * @var array|string
271
     */
272
    public $escapeChar = '"';
273

274
    /**
275
     * ESCAPE statement string
276
     *
277
     * @var string
278
     */
279
    public $likeEscapeStr = " ESCAPE '%s' ";
280

281
    /**
282
     * ESCAPE character
283
     *
284
     * @var string
285
     */
286
    public $likeEscapeChar = '!';
287

288
    /**
289
     * RegExp used to escape identifiers
290
     *
291
     * @var array
292
     */
293
    protected $pregEscapeChar = [];
294

295
    /**
296
     * Holds previously looked up data
297
     * for performance reasons.
298
     *
299
     * @var array
300
     */
301
    public $dataCache = [];
302

303
    /**
304
     * Microtime when connection was made
305
     *
306
     * @var float
307
     */
308
    protected $connectTime = 0.0;
309

310
    /**
311
     * How long it took to establish connection.
312
     *
313
     * @var float
314
     */
315
    protected $connectDuration = 0.0;
316

317
    /**
318
     * If true, no queries will actually be
319
     * run against the database.
320
     *
321
     * @var bool
322
     */
323
    protected $pretend = false;
324

325
    /**
326
     * Transaction enabled flag
327
     *
328
     * @var bool
329
     */
330
    public $transEnabled = true;
331

332
    /**
333
     * Strict transaction mode flag
334
     *
335
     * @var bool
336
     */
337
    public $transStrict = true;
338

339
    /**
340
     * Transaction depth level
341
     *
342
     * @var int
343
     */
344
    protected $transDepth = 0;
345

346
    /**
347
     * Transaction status flag
348
     *
349
     * Used with transactions to determine if a rollback should occur.
350
     *
351
     * @var bool
352
     */
353
    protected $transStatus = true;
354

355
    /**
356
     * Transaction failure flag
357
     *
358
     * Used with transactions to determine if a transaction has failed.
359
     *
360
     * @var bool
361
     */
362
    protected $transFailure = false;
363

364
    /**
365
     * Whether to throw exceptions during transaction
366
     */
367
    protected bool $transException = false;
368

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

376
    /**
377
     * Callbacks to run after the outermost transaction rolls back.
378
     *
379
     * @var list<callable(): void>
380
     */
381
    protected array $transRollbackCallbacks = [];
382

383
    /**
384
     * Array of table aliases.
385
     *
386
     * @var list<string>
387
     */
388
    protected $aliasedTables = [];
389

390
    /**
391
     * Query Class
392
     *
393
     * @var string
394
     */
395
    protected $queryClass = Query::class;
396

397
    /**
398
     * Default Date/Time formats
399
     *
400
     * @var array<string, string>
401
     */
402
    protected array $dateFormat = [
403
        'date'        => 'Y-m-d',
404
        'datetime'    => 'Y-m-d H:i:s',
405
        'datetime-ms' => 'Y-m-d H:i:s.v',
406
        'datetime-us' => 'Y-m-d H:i:s.u',
407
        'time'        => 'H:i:s',
408
    ];
409

410
    /**
411
     * Saves our connection settings.
412
     */
413
    public function __construct(array $params)
414
    {
415
        if (isset($params['dateFormat'])) {
604✔
416
            $this->dateFormat = array_merge($this->dateFormat, $params['dateFormat']);
177✔
417
            unset($params['dateFormat']);
177✔
418
        }
419

420
        $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($params));
604✔
421

422
        foreach ($params as $key => $value) {
604✔
423
            if (property_exists($this, $key)) {
225✔
424
                $this->{$key} = $this->castScalarValueForTypedProperty(
225✔
425
                    $value,
225✔
426
                    $typedPropertyTypes[$key] ?? [],
225✔
427
                );
225✔
428
            }
429
        }
430

431
        $queryClass = str_replace('Connection', 'Query', static::class);
603✔
432

433
        if (class_exists($queryClass)) {
603✔
434
            $this->queryClass = $queryClass;
468✔
435
        }
436

437
        if ($this->failover !== []) {
603✔
438
            // If there is a failover database, connect now to do failover.
439
            // Otherwise, Query Builder creates SQL statement with the main database config
440
            // (DBPrefix) even when the main database is down.
441
            $this->initialize();
2✔
442
        }
443
    }
444

445
    /**
446
     * Some config values (especially env overrides without clear source type)
447
     * can still reach us as strings. Coerce them for typed properties to keep
448
     * strict typing compatible.
449
     *
450
     * @param list<string> $types
451
     */
452
    private function castScalarValueForTypedProperty(mixed $value, array $types): mixed
453
    {
454
        if (! is_string($value)) {
225✔
455
            return $value;
193✔
456
        }
457

458
        if ($types === [] || in_array('string', $types, true) || in_array('mixed', $types, true)) {
225✔
459
            return $value;
225✔
460
        }
461

462
        $trimmedValue = trim($value);
5✔
463

464
        if (in_array('null', $types, true) && strtolower($trimmedValue) === 'null') {
5✔
465
            return null;
1✔
466
        }
467

468
        if (in_array('int', $types, true) && preg_match('/^[+-]?\d+$/', $trimmedValue) === 1) {
5✔
469
            return (int) $trimmedValue;
2✔
470
        }
471

472
        if (in_array('float', $types, true) && is_numeric($trimmedValue)) {
4✔
473
            return (float) $trimmedValue;
×
474
        }
475

476
        if (in_array('bool', $types, true) || in_array('false', $types, true) || in_array('true', $types, true)) {
4✔
477
            $boolValue = filter_var($trimmedValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
3✔
478

479
            if ($boolValue !== null) {
3✔
480
                if (in_array('bool', $types, true)) {
3✔
481
                    return $boolValue;
2✔
482
                }
483

484
                if ($boolValue === false && in_array('false', $types, true)) {
1✔
485
                    return false;
1✔
486
                }
487

488
                if ($boolValue === true && in_array('true', $types, true)) {
1✔
489
                    return true;
1✔
490
                }
491
            }
492
        }
493

494
        return $value;
1✔
495
    }
496

497
    /**
498
     * @param list<string> $properties
499
     *
500
     * @return array<string, list<string>>
501
     */
502
    private function getBuiltinPropertyTypesMap(array $properties): array
503
    {
504
        $className = static::class;
604✔
505
        $requested = array_fill_keys($properties, true);
604✔
506

507
        if (! isset(self::$propertyBuiltinTypesCache[$className])) {
604✔
508
            self::$propertyBuiltinTypesCache[$className] = [];
26✔
509
        }
510

511
        // Fill only the properties requested by this call that are not cached yet.
512
        $missing = array_diff_key($requested, self::$propertyBuiltinTypesCache[$className]);
604✔
513

514
        if ($missing !== []) {
604✔
515
            $reflection = new ReflectionClass($className);
37✔
516

517
            foreach ($reflection->getProperties() as $property) {
37✔
518
                $propertyName = $property->getName();
37✔
519

520
                if (! isset($missing[$propertyName])) {
37✔
521
                    continue;
37✔
522
                }
523

524
                $type = $property->getType();
37✔
525

526
                if (! $type instanceof ReflectionType) {
37✔
527
                    self::$propertyBuiltinTypesCache[$className][$propertyName] = [];
36✔
528

529
                    continue;
36✔
530
                }
531

532
                $namedTypes   = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type];
18✔
533
                $builtinTypes = [];
18✔
534

535
                foreach ($namedTypes as $namedType) {
18✔
536
                    if (! $namedType instanceof ReflectionNamedType || ! $namedType->isBuiltin()) {
18✔
537
                        continue;
×
538
                    }
539

540
                    $builtinTypes[] = $namedType->getName();
18✔
541
                }
542

543
                if ($type->allowsNull() && ! in_array('null', $builtinTypes, true)) {
18✔
544
                    $builtinTypes[] = 'null';
13✔
545
                }
546

547
                self::$propertyBuiltinTypesCache[$className][$propertyName] = $builtinTypes;
18✔
548
            }
549

550
            // Untyped or unresolved properties are cached as empty to avoid re-reflecting them.
551
            foreach (array_keys($missing) as $propertyName) {
37✔
552
                self::$propertyBuiltinTypesCache[$className][$propertyName] ??= [];
37✔
553
            }
554
        }
555

556
        $typedProperties = [];
604✔
557

558
        foreach ($properties as $property) {
604✔
559
            $typedProperties[$property] = self::$propertyBuiltinTypesCache[$className][$property] ?? [];
225✔
560
        }
561

562
        return $typedProperties;
604✔
563
    }
564

565
    /**
566
     * Initializes the database connection/settings.
567
     *
568
     * @return void
569
     *
570
     * @throws DatabaseException
571
     */
572
    public function initialize()
573
    {
574
        /* If an established connection is available, then there's
575
         * no need to connect and select the database.
576
         *
577
         * Depending on the database driver, connID can be either
578
         * boolean TRUE, a resource or an object.
579
         */
580
        if ($this->connID) {
941✔
581
            return;
848✔
582
        }
583

584
        $this->connectTime = microtime(true);
108✔
585
        $connectionErrors  = [];
108✔
586

587
        try {
588
            // Connect to the database and set the connection ID
589
            $this->connID = $this->connect($this->pConnect);
108✔
590
        } catch (Throwable $e) {
2✔
591
            $this->connID       = false;
2✔
592
            $connectionErrors[] = sprintf(
2✔
593
                'Main connection [%s]: %s',
2✔
594
                $this->DBDriver,
2✔
595
                $e->getMessage(),
2✔
596
            );
2✔
597
            log_message('error', 'Error connecting to the database: ' . $e);
2✔
598
        }
599

600
        // No connection resource? Check if there is a failover else throw an error
601
        if (! $this->connID) {
108✔
602
            // Check if there is a failover set
603
            if (! empty($this->failover) && is_array($this->failover)) {
4✔
604
                // Go over all the failovers
605
                foreach ($this->failover as $index => $failover) {
2✔
606
                    $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($failover));
2✔
607

608
                    // Replace the current settings with those of the failover
609
                    foreach ($failover as $key => $val) {
2✔
610
                        if (property_exists($this, $key)) {
2✔
611
                            $this->{$key} = $this->castScalarValueForTypedProperty(
2✔
612
                                $val,
2✔
613
                                $typedPropertyTypes[$key] ?? [],
2✔
614
                            );
2✔
615
                        }
616
                    }
617

618
                    try {
619
                        // Try to connect
620
                        $this->connID = $this->connect($this->pConnect);
2✔
621
                    } catch (Throwable $e) {
1✔
622
                        $connectionErrors[] = sprintf(
1✔
623
                            'Failover #%d [%s]: %s',
1✔
624
                            ++$index,
1✔
625
                            $this->DBDriver,
1✔
626
                            $e->getMessage(),
1✔
627
                        );
1✔
628
                        log_message('error', 'Error connecting to the database: ' . $e);
1✔
629
                    }
630

631
                    // If a connection is made break the foreach loop
632
                    if ($this->connID) {
2✔
633
                        break;
2✔
634
                    }
635
                }
636
            }
637

638
            // We still don't have a connection?
639
            if (! $this->connID) {
4✔
640
                throw new DatabaseException(sprintf(
2✔
641
                    'Unable to connect to the database.%s%s',
2✔
642
                    PHP_EOL,
2✔
643
                    implode(PHP_EOL, $connectionErrors),
2✔
644
                ));
2✔
645
            }
646
        }
647

648
        $this->connectDuration = microtime(true) - $this->connectTime;
106✔
649
    }
650

651
    /**
652
     * Close the database connection.
653
     *
654
     * @return void
655
     */
656
    public function close()
657
    {
658
        if ($this->connID) {
6✔
659
            $this->_close();
5✔
660
            $this->connID = false;
5✔
661
        }
662
    }
663

664
    /**
665
     * Keep or establish the connection if no queries have been sent for
666
     * a length of time exceeding the server's idle timeout.
667
     *
668
     * @return void
669
     */
670
    public function reconnect()
671
    {
672
        if ($this->ping() === false) {
2✔
673
            $this->close();
1✔
674
            $this->initialize();
1✔
675
        }
676
    }
677

678
    /**
679
     * Platform dependent way method for closing the connection.
680
     *
681
     * @return void
682
     */
683
    abstract protected function _close();
684

685
    /**
686
     * Check if the connection is still alive.
687
     */
688
    public function ping(): bool
689
    {
690
        if ($this->connID === false) {
5✔
691
            return false;
2✔
692
        }
693

694
        return $this->_ping();
4✔
695
    }
696

697
    /**
698
     * Driver-specific ping implementation.
699
     */
700
    protected function _ping(): bool
701
    {
702
        try {
703
            $result = $this->simpleQuery('SELECT 1');
4✔
704

705
            return $result !== false;
4✔
706
        } catch (DatabaseException) {
×
707
            return false;
×
708
        }
709
    }
710

711
    /**
712
     * Create a persistent database connection.
713
     *
714
     * @return false|TConnection
715
     */
716
    public function persistentConnect()
717
    {
718
        return $this->connect(true);
×
719
    }
720

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

735
    /**
736
     * Returns the name of the current database being used.
737
     */
738
    public function getDatabase(): string
739
    {
740
        return empty($this->database) ? '' : $this->database;
872✔
741
    }
742

743
    /**
744
     * Set DB Prefix
745
     *
746
     * Set's the DB Prefix to something new without needing to reconnect
747
     *
748
     * @param string $prefix The prefix
749
     */
750
    public function setPrefix(string $prefix = ''): string
751
    {
752
        return $this->DBPrefix = $prefix;
14✔
753
    }
754

755
    /**
756
     * Returns the database prefix.
757
     */
758
    public function getPrefix(): string
759
    {
760
        return $this->DBPrefix;
13✔
761
    }
762

763
    /**
764
     * The name of the platform in use (MySQLi, Postgre, SQLite3, OCI8, etc)
765
     */
766
    public function getPlatform(): string
767
    {
768
        return $this->DBDriver;
23✔
769
    }
770

771
    /**
772
     * Sets the Table Aliases to use. These are typically
773
     * collected during use of the Builder, and set here
774
     * so queries are built correctly.
775
     *
776
     * @return $this
777
     */
778
    public function setAliasedTables(array $aliases)
779
    {
780
        $this->aliasedTables = $aliases;
1,140✔
781

782
        return $this;
1,140✔
783
    }
784

785
    /**
786
     * Add a table alias to our list.
787
     *
788
     * @return $this
789
     */
790
    public function addTableAlias(string $alias)
791
    {
792
        if ($alias === '') {
39✔
793
            return $this;
6✔
794
        }
795

796
        if (! in_array($alias, $this->aliasedTables, true)) {
33✔
797
            $this->aliasedTables[] = $alias;
33✔
798
        }
799

800
        return $this;
33✔
801
    }
802

803
    /**
804
     * Executes the query against the database.
805
     *
806
     * @return false|TResult
807
     */
808
    abstract protected function execute(string $sql);
809

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

828
        if (empty($this->connID)) {
920✔
829
            $this->initialize();
68✔
830
        }
831

832
        /** @var Query $query */
833
        $query = new $queryClass($this);
920✔
834

835
        $query->setQuery($sql, $binds, $setEscapeFlags);
920✔
836

837
        if (! empty($this->swapPre) && ! empty($this->DBPrefix)) {
920✔
838
            $query->swapPrefix($this->DBPrefix, $this->swapPre);
×
839
        }
840

841
        $startTime = microtime(true);
920✔
842

843
        // Always save the last query so we can use
844
        // the getLastQuery() method.
845
        $this->lastQuery = $query;
920✔
846

847
        // If $pretend is true, then we just want to return
848
        // the actual query object here. There won't be
849
        // any results to return.
850
        if ($this->pretend) {
920✔
851
            $query->setDuration($startTime);
11✔
852

853
            return $query;
11✔
854
        }
855

856
        // Run the query for real
857
        try {
858
            $exception           = null;
920✔
859
            $this->lastException = null;
920✔
860
            $this->resultID      = $this->simpleQuery($query->getQuery());
920✔
861
        } catch (DatabaseException $exception) {
23✔
862
            $this->resultID = false;
23✔
863
        }
864

865
        if ($this->resultID === false) {
920✔
866
            $query->setDuration($startTime, $startTime);
49✔
867

868
            // This will trigger a rollback if transactions are being used
869
            $this->handleTransStatus($exception ?? $this->lastException);
49✔
870

871
            if (
872
                $this->DBDebug
49✔
873
                && (
874
                    // Not in transactions
875
                    $this->transDepth === 0
49✔
876
                    // In transactions, do not throw exception by default.
49✔
877
                    || $this->transException
49✔
878
                )
879
            ) {
880
                // We call this function in order to roll-back queries
881
                // if transactions are enabled. If we don't call this here
882
                // the error message will trigger an exit, causing the
883
                // transactions to remain in limbo.
884
                while ($this->transDepth !== 0) {
18✔
885
                    $transDepth = $this->transDepth;
6✔
886
                    $this->transComplete();
6✔
887

888
                    if ($transDepth === $this->transDepth) {
6✔
889
                        log_message('error', 'Database: Failure during an automated transaction commit/rollback!');
×
890
                        break;
×
891
                    }
892
                }
893

894
                // Let others do something with this query.
895
                Events::trigger('DBQuery', $query);
18✔
896

897
                if ($exception instanceof DatabaseException) {
18✔
898
                    throw $exception;
16✔
899
                }
900

901
                return false;
2✔
902
            }
903

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

907
            return false;
31✔
908
        }
909

910
        $query->setDuration($startTime);
919✔
911

912
        // Let others do something with this query
913
        Events::trigger('DBQuery', $query);
919✔
914

915
        // resultID is not false, so it must be successful
916
        if ($this->isWriteType($sql)) {
919✔
917
            return true;
887✔
918
        }
919

920
        // query is not write-type, so it must be read-type query; return QueryResult
921
        $resultClass = str_replace('Connection', 'Result', static::class);
918✔
922

923
        return new $resultClass($this->connID, $this->resultID);
918✔
924
    }
925

926
    /**
927
     * Performs a basic query against the database. No binding or caching
928
     * is performed, nor are transactions handled. Simply takes a raw
929
     * query string and returns the database-specific result id.
930
     *
931
     * @return false|TResult
932
     */
933
    public function simpleQuery(string $sql)
934
    {
935
        if (empty($this->connID)) {
928✔
936
            $this->initialize();
7✔
937
        }
938

939
        return $this->execute($sql);
928✔
940
    }
941

942
    /**
943
     * Disable Transactions
944
     *
945
     * This permits transactions to be disabled at run-time.
946
     *
947
     * @return void
948
     */
949
    public function transOff()
950
    {
951
        $this->transEnabled = false;
2✔
952
    }
953

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

972
        return $this;
6✔
973
    }
974

975
    /**
976
     * Start Transaction
977
     */
978
    public function transStart(bool $testMode = false): bool
979
    {
980
        if (! $this->transEnabled) {
66✔
981
            return false;
×
982
        }
983

984
        return $this->transBegin($testMode);
66✔
985
    }
986

987
    /**
988
     * If set to true, exceptions are thrown during transactions.
989
     *
990
     * @return $this
991
     */
992
    public function transException(bool $transException)
993
    {
994
        $this->transException = $transException;
6✔
995

996
        return $this;
6✔
997
    }
998

999
    /**
1000
     * Complete Transaction
1001
     */
1002
    public function transComplete(): bool
1003
    {
1004
        if (! $this->transEnabled) {
84✔
1005
            return false;
×
1006
        }
1007

1008
        // The query() function will set this flag to FALSE in the event that a query failed
1009
        if ($this->transStatus === false || $this->transFailure === true) {
84✔
1010
            try {
1011
                $this->transRollback();
35✔
1012
            } finally {
1013
                // If we are NOT running in strict mode, we will reset
1014
                // the _trans_status flag so that subsequent groups of
1015
                // transactions will be permitted.
1016
                if ($this->transStrict === false) {
35✔
1017
                    $this->transStatus = true;
35✔
1018
                }
1019
            }
1020

1021
            return false;
33✔
1022
        }
1023

1024
        return $this->transCommit();
58✔
1025
    }
1026

1027
    /**
1028
     * Lets you retrieve the transaction flag to determine if it has failed
1029
     */
1030
    public function transStatus(): bool
1031
    {
1032
        return $this->transStatus;
21✔
1033
    }
1034

1035
    /**
1036
     * Checks whether this connection is inside an active transaction.
1037
     */
1038
    public function inTransaction(): bool
1039
    {
1040
        return $this->transDepth > 0;
6✔
1041
    }
1042

1043
    /**
1044
     * Register a callback to run after the outermost transaction commits.
1045
     *
1046
     * If no transaction is active, the callback runs immediately.
1047
     *
1048
     * @param callable(): void $callback
1049
     *
1050
     * @return $this
1051
     */
1052
    public function afterCommit(callable $callback): static
1053
    {
1054
        if ($this->transDepth === 0) {
13✔
1055
            $callback();
2✔
1056

1057
            return $this;
2✔
1058
        }
1059

1060
        $this->transCommitCallbacks[] = $callback;
11✔
1061

1062
        return $this;
11✔
1063
    }
1064

1065
    /**
1066
     * Register a callback to run after the outermost transaction rolls back.
1067
     *
1068
     * If no transaction is active, the callback is not run.
1069
     *
1070
     * @param callable(): void $callback
1071
     *
1072
     * @return $this
1073
     */
1074
    public function afterRollback(callable $callback): static
1075
    {
1076
        if ($this->transDepth === 0) {
15✔
1077
            return $this;
1✔
1078
        }
1079

1080
        $this->transRollbackCallbacks[] = $callback;
14✔
1081

1082
        return $this;
14✔
1083
    }
1084

1085
    /**
1086
     * Run the callback inside a transaction.
1087
     *
1088
     * @template TReturn
1089
     *
1090
     * @param callable(self): TReturn $callback
1091
     * @param positive-int            $attempts
1092
     * @param bool|null               $transException   Temporarily override transaction exception mode.
1093
     * @param bool                    $resetTransStatus Reset transaction status before an outermost transaction starts.
1094
     *
1095
     * @return false|TReturn
1096
     */
1097
    public function transaction(
1098
        callable $callback,
1099
        int $attempts = 1,
1100
        ?bool $transException = null,
1101
        bool $resetTransStatus = false,
1102
    ): mixed {
1103
        if ($attempts < 1) {
36✔
1104
            throw new InvalidArgumentException('Transaction attempts must be a positive integer.');
1✔
1105
        }
1106

1107
        $restoreTransException  = $transException !== null;
35✔
1108
        $previousTransException = $this->transException;
35✔
1109

1110
        if ($restoreTransException) {
35✔
1111
            $this->transException = $transException;
5✔
1112
        }
1113

1114
        try {
1115
            if (! $this->transEnabled) {
35✔
1116
                return $callback($this);
1✔
1117
            }
1118

1119
            $outermostTransaction = $this->transDepth === 0;
34✔
1120

1121
            if ($resetTransStatus && $outermostTransaction) {
34✔
1122
                $this->resetTransStatus();
1✔
1123
            }
1124

1125
            $attempts = $outermostTransaction ? $attempts : 1;
34✔
1126

1127
            for ($attempt = 1; $attempt <= $attempts; $attempt++) {
34✔
1128
                if (! $this->transBegin()) {
34✔
1129
                    return false;
1✔
1130
                }
1131

1132
                try {
1133
                    $result = $callback($this);
33✔
1134
                } catch (Throwable $e) {
16✔
1135
                    try {
1136
                        $this->transRollback();
16✔
1137
                    } catch (Throwable $rollbackException) {
2✔
1138
                        log_message('error', 'Database: Transaction callback threw an exception before rollback failed: ' . $e);
2✔
1139

1140
                        throw $rollbackException;
2✔
1141
                    } finally {
1142
                        if ($this->transDepth > 0) {
16✔
1143
                            $this->transStatus = false;
3✔
1144
                        } elseif ($this->transStrict === false) {
13✔
1145
                            $this->transStatus = true;
16✔
1146
                        }
1147
                    }
1148

1149
                    if ($this->transDepth === 0 && $e instanceof RetryableTransactionException && $attempt < $attempts) {
14✔
1150
                        $this->prepareTransactionRetry();
5✔
1151

1152
                        continue;
5✔
1153
                    }
1154

1155
                    throw $e;
10✔
1156
                }
1157

1158
                if (! $this->transComplete()) {
21✔
1159
                    if ($this->transDepth === 0 && $this->transFailureException instanceof RetryableTransactionException && $attempt < $attempts) {
9✔
1160
                        $this->prepareTransactionRetry();
2✔
1161

1162
                        continue;
2✔
1163
                    }
1164

1165
                    return false;
7✔
1166
                }
1167

1168
                return $result;
14✔
1169
            }
1170

NEW
1171
            return false;
×
1172
        } finally {
1173
            if ($restoreTransException) {
35✔
1174
                $this->transException = $previousTransException;
35✔
1175
            }
1176
        }
1177
    }
1178

1179
    /**
1180
     * Begin Transaction
1181
     */
1182
    public function transBegin(bool $testMode = false): bool
1183
    {
1184
        if (! $this->transEnabled) {
104✔
1185
            return false;
1✔
1186
        }
1187

1188
        // When transactions are nested we only begin/commit/rollback the outermost ones
1189
        if ($this->transDepth > 0) {
103✔
1190
            $this->transDepth++;
9✔
1191

1192
            return true;
9✔
1193
        }
1194

1195
        if (empty($this->connID)) {
103✔
1196
            $this->initialize();
12✔
1197
        }
1198

1199
        // Reset the transaction failure flag.
1200
        // If the $testMode flag is set to TRUE transactions will be rolled back
1201
        // even if the queries produce a successful result.
1202
        $this->transFailure          = $testMode;
103✔
1203
        $this->transFailureException = null;
103✔
1204

1205
        if ($this->_transBegin()) {
103✔
1206
            $this->transDepth++;
102✔
1207

1208
            return true;
102✔
1209
        }
1210

1211
        return false;
1✔
1212
    }
1213

1214
    /**
1215
     * Commit Transaction
1216
     */
1217
    public function transCommit(): bool
1218
    {
1219
        if (! $this->transEnabled || $this->transDepth === 0) {
62✔
1220
            return false;
×
1221
        }
1222

1223
        // When transactions are nested we only begin/commit/rollback the outermost ones
1224
        if ($this->transDepth > 1 || $this->_transCommit()) {
62✔
1225
            $this->transDepth--;
62✔
1226

1227
            if ($this->transDepth === 0) {
62✔
1228
                $this->transRollbackCallbacks = [];
61✔
1229
                $this->runTransCommitCallbacks();
61✔
1230
            }
1231

1232
            return true;
60✔
1233
        }
1234

1235
        return false;
1✔
1236
    }
1237

1238
    /**
1239
     * Rollback Transaction
1240
     */
1241
    public function transRollback(): bool
1242
    {
1243
        if (! $this->transEnabled || $this->transDepth === 0) {
54✔
1244
            return false;
3✔
1245
        }
1246

1247
        // When transactions are nested we only begin/commit/rollback the outermost ones
1248
        if ($this->transDepth > 1 || $this->_transRollback()) {
54✔
1249
            $this->transDepth--;
52✔
1250

1251
            if ($this->transDepth === 0) {
52✔
1252
                $this->transCommitCallbacks = [];
52✔
1253
                $this->runTransRollbackCallbacks();
52✔
1254
            }
1255

1256
            return true;
47✔
1257
        }
1258

1259
        return false;
3✔
1260
    }
1261

1262
    /**
1263
     * Reset transaction status - to restart transactions after strict mode failure
1264
     */
1265
    public function resetTransStatus(): static
1266
    {
1267
        $this->transStatus = true;
6✔
1268

1269
        return $this;
6✔
1270
    }
1271

1272
    /**
1273
     * Handle transaction status when a query fails
1274
     *
1275
     * @internal This method is for internal database component use only
1276
     */
1277
    public function handleTransStatus(?DatabaseException $exception = null): void
1278
    {
1279
        if ($this->transDepth !== 0) {
61✔
1280
            $this->transStatus = false;
32✔
1281
            $this->transFailureException ??= $exception;
32✔
1282
        }
1283
    }
1284

1285
    /**
1286
     * Reset transaction state that should not leak into a retry attempt.
1287
     */
1288
    protected function prepareTransactionRetry(): void
1289
    {
1290
        $this->transStatus           = true;
7✔
1291
        $this->transFailureException = null;
7✔
1292
        $this->lastException         = null;
7✔
1293
    }
1294

1295
    /**
1296
     * Run and clear callbacks registered for a successful transaction commit.
1297
     */
1298
    protected function runTransCommitCallbacks(): void
1299
    {
1300
        $callbacks                  = $this->transCommitCallbacks;
61✔
1301
        $this->transCommitCallbacks = [];
61✔
1302

1303
        foreach ($callbacks as $callback) {
61✔
1304
            $callback();
10✔
1305
        }
1306
    }
1307

1308
    /**
1309
     * Run and clear callbacks registered for a transaction rollback.
1310
     */
1311
    protected function runTransRollbackCallbacks(): void
1312
    {
1313
        $callbacks                    = $this->transRollbackCallbacks;
52✔
1314
        $this->transRollbackCallbacks = [];
52✔
1315

1316
        foreach ($callbacks as $callback) {
52✔
1317
            $callback();
13✔
1318
        }
1319
    }
1320

1321
    /**
1322
     * Begin Transaction
1323
     */
1324
    abstract protected function _transBegin(): bool;
1325

1326
    /**
1327
     * Commit Transaction
1328
     */
1329
    abstract protected function _transCommit(): bool;
1330

1331
    /**
1332
     * Rollback Transaction
1333
     */
1334
    abstract protected function _transRollback(): bool;
1335

1336
    /**
1337
     * Returns a non-shared new instance of the query builder for this connection.
1338
     *
1339
     * @param array|string|TableName $tableName
1340
     *
1341
     * @return BaseBuilder
1342
     *
1343
     * @throws DatabaseException
1344
     */
1345
    public function table($tableName)
1346
    {
1347
        if (empty($tableName)) {
1,080✔
1348
            throw new DatabaseException('You must set the database table to be used with your query.');
×
1349
        }
1350

1351
        $className = str_replace('Connection', 'Builder', static::class);
1,080✔
1352

1353
        return new $className($tableName, $this);
1,080✔
1354
    }
1355

1356
    /**
1357
     * Returns a new instance of the BaseBuilder class with a cleared FROM clause.
1358
     */
1359
    public function newQuery(): BaseBuilder
1360
    {
1361
        // save table aliases
1362
        $tempAliases         = $this->aliasedTables;
22✔
1363
        $builder             = $this->table(',')->from([], true);
22✔
1364
        $this->aliasedTables = $tempAliases;
22✔
1365

1366
        return $builder;
22✔
1367
    }
1368

1369
    /**
1370
     * Creates a prepared statement with the database that can then
1371
     * be used to execute multiple statements against. Within the
1372
     * closure, you would build the query in any normal way, though
1373
     * the Query Builder is the expected manner.
1374
     *
1375
     * Example:
1376
     *    $stmt = $db->prepare(function($db)
1377
     *           {
1378
     *             return $db->table('users')
1379
     *                   ->where('id', 1)
1380
     *                     ->get();
1381
     *           })
1382
     *
1383
     * @param Closure(BaseConnection): mixed $func
1384
     *
1385
     * @return BasePreparedQuery|null
1386
     */
1387
    public function prepare(Closure $func, array $options = [])
1388
    {
1389
        if (empty($this->connID)) {
17✔
1390
            $this->initialize();
×
1391
        }
1392

1393
        $this->pretend();
17✔
1394

1395
        $sql = $func($this);
17✔
1396

1397
        $this->pretend(false);
17✔
1398

1399
        if ($sql instanceof QueryInterface) {
17✔
1400
            $sql = $sql->getOriginalQuery();
17✔
1401
        }
1402

1403
        $class = str_ireplace('Connection', 'PreparedQuery', static::class);
17✔
1404
        /** @var BasePreparedQuery $class */
1405
        $class = new $class($this);
17✔
1406

1407
        return $class->prepare($sql, $options);
17✔
1408
    }
1409

1410
    /**
1411
     * Returns the last query's statement object.
1412
     *
1413
     * @return Query
1414
     */
1415
    public function getLastQuery()
1416
    {
1417
        return $this->lastQuery;
11✔
1418
    }
1419

1420
    /**
1421
     * Returns a string representation of the last query's statement object.
1422
     */
1423
    public function showLastQuery(): string
1424
    {
1425
        return (string) $this->lastQuery;
×
1426
    }
1427

1428
    /**
1429
     * Returns the time we started to connect to this database in
1430
     * seconds with microseconds.
1431
     *
1432
     * Used by the Debug Toolbar's timeline.
1433
     */
1434
    public function getConnectStart(): ?float
1435
    {
1436
        return $this->connectTime;
1✔
1437
    }
1438

1439
    /**
1440
     * Returns the number of seconds with microseconds that it took
1441
     * to connect to the database.
1442
     *
1443
     * Used by the Debug Toolbar's timeline.
1444
     */
1445
    public function getConnectDuration(int $decimals = 6): string
1446
    {
1447
        return number_format($this->connectDuration, $decimals);
2✔
1448
    }
1449

1450
    /**
1451
     * Protect Identifiers
1452
     *
1453
     * This function is used extensively by the Query Builder class, and by
1454
     * a couple functions in this class.
1455
     * It takes a column or table name (optionally with an alias) and inserts
1456
     * the table prefix onto it. Some logic is necessary in order to deal with
1457
     * column names that include the path. Consider a query like this:
1458
     *
1459
     * SELECT hostname.database.table.column AS c FROM hostname.database.table
1460
     *
1461
     * Or a query with aliasing:
1462
     *
1463
     * SELECT m.member_id, m.member_name FROM members AS m
1464
     *
1465
     * Since the column name can include up to four segments (host, DB, table, column)
1466
     * or also have an alias prefix, we need to do a bit of work to figure this out and
1467
     * insert the table prefix (if it exists) in the proper position, and escape only
1468
     * the correct identifiers.
1469
     *
1470
     * @param array|int|string|TableName $item
1471
     * @param bool                       $prefixSingle       Prefix a table name with no segments?
1472
     * @param bool                       $protectIdentifiers Protect table or column names?
1473
     * @param bool                       $fieldExists        Supplied $item contains a column name?
1474
     *
1475
     * @return ($item is array ? array : string)
1476
     */
1477
    public function protectIdentifiers($item, bool $prefixSingle = false, ?bool $protectIdentifiers = null, bool $fieldExists = true)
1478
    {
1479
        if (! is_bool($protectIdentifiers)) {
1,289✔
1480
            $protectIdentifiers = $this->protectIdentifiers;
1,256✔
1481
        }
1482

1483
        if (is_array($item)) {
1,289✔
1484
            $escapedArray = [];
1✔
1485

1486
            foreach ($item as $k => $v) {
1✔
1487
                $escapedArray[$this->protectIdentifiers($k)] = $this->protectIdentifiers($v, $prefixSingle, $protectIdentifiers, $fieldExists);
1✔
1488
            }
1489

1490
            return $escapedArray;
1✔
1491
        }
1492

1493
        if ($item instanceof TableName) {
1,289✔
1494
            /** @psalm-suppress NoValue I don't know why ERROR. */
1495
            return $this->escapeTableName($item);
6✔
1496
        }
1497

1498
        // If you pass `['column1', 'column2']`, `$item` will be int because the array keys are int.
1499
        $item = (string) $item;
1,289✔
1500

1501
        // This is basically a bug fix for queries that use MAX, MIN, etc.
1502
        // If a parenthesis is found we know that we do not need to
1503
        // escape the data or add a prefix. There's probably a more graceful
1504
        // way to deal with this, but I'm not thinking of it
1505
        //
1506
        // Added exception for single quotes as well, we don't want to alter
1507
        // literal strings.
1508
        if (strcspn($item, "()'") !== strlen($item)) {
1,289✔
1509
            /** @psalm-suppress NoValue I don't know why ERROR. */
1510
            return $item;
891✔
1511
        }
1512

1513
        // Do not protect identifiers and do not prefix, no swap prefix, there is nothing to do
1514
        if ($protectIdentifiers === false && $prefixSingle === false && $this->swapPre === '') {
1,278✔
1515
            /** @psalm-suppress NoValue I don't know why ERROR. */
1516
            return $item;
113✔
1517
        }
1518

1519
        // Convert tabs or multiple spaces into single spaces
1520
        /** @psalm-suppress NoValue I don't know why ERROR. */
1521
        $item = preg_replace('/\s+/', ' ', trim($item));
1,277✔
1522

1523
        // If the item has an alias declaration we remove it and set it aside.
1524
        // Note: strripos() is used in order to support spaces in table names
1525
        if ($offset = strripos($item, ' AS ')) {
1,277✔
1526
            $alias = ($protectIdentifiers) ? substr($item, $offset, 4) . $this->escapeIdentifiers(substr($item, $offset + 4)) : substr($item, $offset);
11✔
1527
            $item  = substr($item, 0, $offset);
11✔
1528
        } elseif ($offset = strrpos($item, ' ')) {
1,272✔
1529
            $alias = ($protectIdentifiers) ? ' ' . $this->escapeIdentifiers(substr($item, $offset + 1)) : substr($item, $offset);
19✔
1530
            $item  = substr($item, 0, $offset);
19✔
1531
        } else {
1532
            $alias = '';
1,265✔
1533
        }
1534

1535
        // Break the string apart if it contains periods, then insert the table prefix
1536
        // in the correct location, assuming the period doesn't indicate that we're dealing
1537
        // with an alias. While we're at it, we will escape the components
1538
        if (str_contains($item, '.')) {
1,277✔
1539
            return $this->protectDotItem($item, $alias, $protectIdentifiers, $fieldExists);
164✔
1540
        }
1541

1542
        // In some cases, especially 'from', we end up running through
1543
        // protect_identifiers twice. This algorithm won't work when
1544
        // it contains the escapeChar so strip it out.
1545
        $item = trim($item, $this->escapeChar);
1,269✔
1546

1547
        // Is there a table prefix? If not, no need to insert it
1548
        if ($this->DBPrefix !== '') {
1,269✔
1549
            // Verify table prefix and replace if necessary
1550
            if ($this->swapPre !== '' && str_starts_with($item, $this->swapPre)) {
928✔
1551
                $item = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $item);
×
1552
            }
1553
            // Do we prefix an item with no segments?
1554
            elseif ($prefixSingle && ! str_starts_with($item, $this->DBPrefix)) {
928✔
1555
                $item = $this->DBPrefix . $item;
921✔
1556
            }
1557
        }
1558

1559
        if ($protectIdentifiers === true && ! in_array($item, $this->reservedIdentifiers, true)) {
1,269✔
1560
            $item = $this->escapeIdentifiers($item);
1,267✔
1561
        }
1562

1563
        return $item . $alias;
1,269✔
1564
    }
1565

1566
    private function protectDotItem(string $item, string $alias, bool $protectIdentifiers, bool $fieldExists): string
1567
    {
1568
        $parts = explode('.', $item);
164✔
1569

1570
        // Does the first segment of the exploded item match
1571
        // one of the aliases previously identified? If so,
1572
        // we have nothing more to do other than escape the item
1573
        //
1574
        // NOTE: The ! empty() condition prevents this method
1575
        // from breaking when QB isn't enabled.
1576
        if (! empty($this->aliasedTables) && in_array($parts[0], $this->aliasedTables, true)) {
164✔
1577
            if ($protectIdentifiers) {
16✔
1578
                foreach ($parts as $key => $val) {
16✔
1579
                    if (! in_array($val, $this->reservedIdentifiers, true)) {
16✔
1580
                        $parts[$key] = $this->escapeIdentifiers($val);
16✔
1581
                    }
1582
                }
1583

1584
                $item = implode('.', $parts);
16✔
1585
            }
1586

1587
            return $item . $alias;
16✔
1588
        }
1589

1590
        // Is there a table prefix defined in the config file? If not, no need to do anything
1591
        if ($this->DBPrefix !== '') {
153✔
1592
            // We now add the table prefix based on some logic.
1593
            // Do we have 4 segments (hostname.database.table.column)?
1594
            // If so, we add the table prefix to the column name in the 3rd segment.
1595
            if (isset($parts[3])) {
137✔
1596
                $i = 2;
×
1597
            }
1598
            // Do we have 3 segments (database.table.column)?
1599
            // If so, we add the table prefix to the column name in 2nd position
1600
            elseif (isset($parts[2])) {
137✔
1601
                $i = 1;
×
1602
            }
1603
            // Do we have 2 segments (table.column)?
1604
            // If so, we add the table prefix to the column name in 1st segment
1605
            else {
1606
                $i = 0;
137✔
1607
            }
1608

1609
            // This flag is set when the supplied $item does not contain a field name.
1610
            // This can happen when this function is being called from a JOIN.
1611
            if ($fieldExists === false) {
137✔
1612
                $i++;
×
1613
            }
1614

1615
            // Verify table prefix and replace if necessary
1616
            if ($this->swapPre !== '' && str_starts_with($parts[$i], $this->swapPre)) {
137✔
1617
                $parts[$i] = preg_replace('/^' . $this->swapPre . '(\S+?)/', $this->DBPrefix . '\\1', $parts[$i]);
×
1618
            }
1619
            // We only add the table prefix if it does not already exist
1620
            elseif (! str_starts_with($parts[$i], $this->DBPrefix)) {
137✔
1621
                $parts[$i] = $this->DBPrefix . $parts[$i];
137✔
1622
            }
1623

1624
            // Put the parts back together
1625
            $item = implode('.', $parts);
137✔
1626
        }
1627

1628
        if ($protectIdentifiers) {
153✔
1629
            $item = $this->escapeIdentifiers($item);
153✔
1630
        }
1631

1632
        return $item . $alias;
153✔
1633
    }
1634

1635
    /**
1636
     * Escape the SQL Identifier
1637
     *
1638
     * This function escapes single identifier.
1639
     *
1640
     * @param non-empty-string|TableName $item
1641
     */
1642
    public function escapeIdentifier($item): string
1643
    {
1644
        if ($item === '') {
813✔
1645
            return '';
×
1646
        }
1647

1648
        if ($item instanceof TableName) {
813✔
1649
            return $this->escapeTableName($item);
7✔
1650
        }
1651

1652
        return $this->escapeChar
813✔
1653
            . str_replace(
813✔
1654
                $this->escapeChar,
813✔
1655
                $this->escapeChar . $this->escapeChar,
813✔
1656
                $item,
813✔
1657
            )
813✔
1658
            . $this->escapeChar;
813✔
1659
    }
1660

1661
    /**
1662
     * Returns escaped table name with alias.
1663
     */
1664
    private function escapeTableName(TableName $tableName): string
1665
    {
1666
        $alias = $tableName->getAlias();
7✔
1667

1668
        return $this->escapeIdentifier($tableName->getActualTableName())
7✔
1669
            . (($alias !== '') ? ' ' . $this->escapeIdentifier($alias) : '');
7✔
1670
    }
1671

1672
    /**
1673
     * Escape the SQL Identifiers
1674
     *
1675
     * This function escapes column and table names
1676
     *
1677
     * @param array|string $item
1678
     *
1679
     * @return ($item is array ? array : string)
1680
     */
1681
    public function escapeIdentifiers($item)
1682
    {
1683
        if ($this->escapeChar === '' || empty($item) || in_array($item, $this->reservedIdentifiers, true)) {
1,294✔
1684
            return $item;
5✔
1685
        }
1686

1687
        if (is_array($item)) {
1,293✔
1688
            foreach ($item as $key => $value) {
832✔
1689
                $item[$key] = $this->escapeIdentifiers($value);
832✔
1690
            }
1691

1692
            return $item;
832✔
1693
        }
1694

1695
        // Avoid breaking functions and literal values inside queries
1696
        if (ctype_digit($item)
1,293✔
1697
            || $item[0] === "'"
1,292✔
1698
            || ($this->escapeChar !== '"' && $item[0] === '"')
1,292✔
1699
            || str_contains($item, '(')) {
1,293✔
1700
            return $item;
48✔
1701
        }
1702

1703
        if ($this->pregEscapeChar === []) {
1,292✔
1704
            if (is_array($this->escapeChar)) {
440✔
1705
                $this->pregEscapeChar = [
×
1706
                    preg_quote($this->escapeChar[0], '/'),
×
1707
                    preg_quote($this->escapeChar[1], '/'),
×
1708
                    $this->escapeChar[0],
×
1709
                    $this->escapeChar[1],
×
1710
                ];
×
1711
            } else {
1712
                $this->pregEscapeChar[0] = $this->pregEscapeChar[1] = preg_quote($this->escapeChar, '/');
440✔
1713
                $this->pregEscapeChar[2] = $this->pregEscapeChar[3] = $this->escapeChar;
440✔
1714
            }
1715
        }
1716

1717
        foreach ($this->reservedIdentifiers as $id) {
1,292✔
1718
            /** @psalm-suppress NoValue I don't know why ERROR. */
1719
            if (str_contains($item, '.' . $id)) {
1,292✔
1720
                return preg_replace(
3✔
1721
                    '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?\./i',
3✔
1722
                    $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '.',
3✔
1723
                    $item,
3✔
1724
                );
3✔
1725
            }
1726
        }
1727

1728
        /** @psalm-suppress NoValue I don't know why ERROR. */
1729
        return preg_replace(
1,290✔
1730
            '/' . $this->pregEscapeChar[0] . '?([^' . $this->pregEscapeChar[1] . '\.]+)' . $this->pregEscapeChar[1] . '?(\.)?/i',
1,290✔
1731
            $this->pregEscapeChar[2] . '$1' . $this->pregEscapeChar[3] . '$2',
1,290✔
1732
            $item,
1,290✔
1733
        );
1,290✔
1734
    }
1735

1736
    /**
1737
     * Prepends a database prefix if one exists in configuration
1738
     *
1739
     * @throws DatabaseException
1740
     */
1741
    public function prefixTable(string $table = ''): string
1742
    {
1743
        if ($table === '') {
3✔
1744
            throw new DatabaseException('A table name is required for that operation.');
×
1745
        }
1746

1747
        return $this->DBPrefix . $table;
3✔
1748
    }
1749

1750
    /**
1751
     * Returns the total number of rows affected by this query.
1752
     */
1753
    abstract public function affectedRows(): int;
1754

1755
    /**
1756
     * "Smart" Escape String
1757
     *
1758
     * Escapes data based on type.
1759
     * Sets boolean and null types
1760
     *
1761
     * @param array|bool|float|int|object|string|null $str
1762
     *
1763
     * @return ($str is array ? array : float|int|string)
1764
     */
1765
    public function escape($str)
1766
    {
1767
        if (is_array($str)) {
1,051✔
1768
            return array_map($this->escape(...), $str);
846✔
1769
        }
1770

1771
        if ($str instanceof Stringable) {
1,051✔
1772
            if ($str instanceof RawSql) {
13✔
1773
                return $str->__toString();
12✔
1774
            }
1775

1776
            $str = (string) $str;
1✔
1777
        }
1778

1779
        if (is_string($str)) {
1,048✔
1780
            return "'" . $this->escapeString($str) . "'";
974✔
1781
        }
1782

1783
        if (is_bool($str)) {
965✔
1784
            return ($str === false) ? 0 : 1;
8✔
1785
        }
1786

1787
        return $str ?? 'NULL';
963✔
1788
    }
1789

1790
    /**
1791
     * Escape String
1792
     *
1793
     * @param list<string|Stringable>|string|Stringable $str  Input string
1794
     * @param bool                                      $like Whether the string will be used in a LIKE condition
1795
     *
1796
     * @return list<string>|string
1797
     */
1798
    public function escapeString($str, bool $like = false)
1799
    {
1800
        if (is_array($str)) {
974✔
1801
            foreach ($str as $key => $val) {
×
1802
                $str[$key] = $this->escapeString($val, $like);
×
1803
            }
1804

1805
            return $str;
×
1806
        }
1807

1808
        if ($str instanceof Stringable) {
974✔
1809
            if ($str instanceof RawSql) {
2✔
1810
                return $str->__toString();
×
1811
            }
1812

1813
            $str = (string) $str;
2✔
1814
        }
1815

1816
        $str = $this->_escapeString($str);
974✔
1817

1818
        // escape LIKE condition wildcards
1819
        if ($like) {
974✔
1820
            return str_replace(
2✔
1821
                [
2✔
1822
                    $this->likeEscapeChar,
2✔
1823
                    '%',
2✔
1824
                    '_',
2✔
1825
                ],
2✔
1826
                [
2✔
1827
                    $this->likeEscapeChar . $this->likeEscapeChar,
2✔
1828
                    $this->likeEscapeChar . '%',
2✔
1829
                    $this->likeEscapeChar . '_',
2✔
1830
                ],
2✔
1831
                $str,
2✔
1832
            );
2✔
1833
        }
1834

1835
        return $str;
974✔
1836
    }
1837

1838
    /**
1839
     * Escape LIKE String
1840
     *
1841
     * Calls the individual driver for platform
1842
     * specific escaping for LIKE conditions
1843
     *
1844
     * @param list<string|Stringable>|string|Stringable $str
1845
     *
1846
     * @return list<string>|string
1847
     */
1848
    public function escapeLikeString($str)
1849
    {
1850
        return $this->escapeString($str, true);
2✔
1851
    }
1852

1853
    /**
1854
     * Platform independent string escape.
1855
     *
1856
     * Will likely be overridden in child classes.
1857
     */
1858
    protected function _escapeString(string $str): string
1859
    {
1860
        return str_replace("'", "''", remove_invisible_characters($str, false));
928✔
1861
    }
1862

1863
    /**
1864
     * This function enables you to call PHP database functions that are not natively included
1865
     * in CodeIgniter, in a platform independent manner.
1866
     *
1867
     * @param array ...$params
1868
     *
1869
     * @throws DatabaseException
1870
     */
1871
    public function callFunction(string $functionName, ...$params): bool
1872
    {
1873
        $driver = $this->getDriverFunctionPrefix();
2✔
1874

1875
        if (! str_starts_with($functionName, $driver)) {
2✔
1876
            $functionName = $driver . $functionName;
1✔
1877
        }
1878

1879
        if (! function_exists($functionName)) {
2✔
1880
            if ($this->DBDebug) {
×
1881
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1882
            }
1883

1884
            return false;
×
1885
        }
1886

1887
        return $functionName(...$params);
2✔
1888
    }
1889

1890
    /**
1891
     * Get the prefix of the function to access the DB.
1892
     */
1893
    protected function getDriverFunctionPrefix(): string
1894
    {
1895
        return strtolower($this->DBDriver) . '_';
×
1896
    }
1897

1898
    // --------------------------------------------------------------------
1899
    // META Methods
1900
    // --------------------------------------------------------------------
1901

1902
    /**
1903
     * Returns an array of table names
1904
     *
1905
     * @return false|list<string>
1906
     *
1907
     * @throws DatabaseException
1908
     */
1909
    public function listTables(bool $constrainByPrefix = false)
1910
    {
1911
        if (isset($this->dataCache['table_names']) && $this->dataCache['table_names']) {
873✔
1912
            $tables = $constrainByPrefix
867✔
1913
                ? preg_grep("/^{$this->DBPrefix}/", $this->dataCache['table_names'])
2✔
1914
                : $this->dataCache['table_names'];
867✔
1915

1916
            return array_values($tables);
867✔
1917
        }
1918

1919
        $sql = $this->_listTables($constrainByPrefix);
107✔
1920

1921
        if ($sql === false) {
107✔
1922
            if ($this->DBDebug) {
×
1923
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1924
            }
1925

1926
            return false;
×
1927
        }
1928

1929
        $this->dataCache['table_names'] = [];
107✔
1930

1931
        $query = $this->query($sql);
107✔
1932

1933
        foreach ($query->getResultArray() as $row) {
107✔
1934
            /** @var string $table */
1935
            $table = $row['table_name'] ?? $row['TABLE_NAME'] ?? $row[array_key_first($row)];
104✔
1936

1937
            $this->dataCache['table_names'][] = $table;
104✔
1938
        }
1939

1940
        return $this->dataCache['table_names'];
107✔
1941
    }
1942

1943
    /**
1944
     * Determine if a particular table exists
1945
     *
1946
     * @param bool $cached Whether to use data cache
1947
     */
1948
    public function tableExists(string $tableName, bool $cached = true): bool
1949
    {
1950
        if ($cached) {
874✔
1951
            return in_array($this->protectIdentifiers($tableName, true, false, false), $this->listTables(), true);
873✔
1952
        }
1953

1954
        if (false === ($sql = $this->_listTables(false, $tableName))) {
827✔
1955
            if ($this->DBDebug) {
×
1956
                throw new DatabaseException('This feature is not available for the database you are using.');
×
1957
            }
1958

1959
            return false;
×
1960
        }
1961

1962
        $tableExists = $this->query($sql)->getResultArray() !== [];
827✔
1963

1964
        // if cache has been built already
1965
        if (! empty($this->dataCache['table_names'])) {
827✔
1966
            $key = array_search(
823✔
1967
                strtolower($tableName),
823✔
1968
                array_map(strtolower(...), $this->dataCache['table_names']),
823✔
1969
                true,
823✔
1970
            );
823✔
1971

1972
            // table doesn't exist but still in cache - lets reset cache, it can be rebuilt later
1973
            // OR if table does exist but is not found in cache
1974
            if (($key !== false && ! $tableExists) || ($key === false && $tableExists)) {
823✔
1975
                $this->resetDataCache();
1✔
1976
            }
1977
        }
1978

1979
        return $tableExists;
827✔
1980
    }
1981

1982
    /**
1983
     * Fetch Field Names
1984
     *
1985
     * @param string|TableName $tableName
1986
     *
1987
     * @return false|list<string>
1988
     *
1989
     * @throws DatabaseException
1990
     */
1991
    public function getFieldNames($tableName)
1992
    {
1993
        $table = ($tableName instanceof TableName) ? $tableName->getTableName() : $tableName;
12✔
1994

1995
        // Is there a cached result?
1996
        if (isset($this->dataCache['field_names'][$table])) {
12✔
1997
            return $this->dataCache['field_names'][$table];
3✔
1998
        }
1999

2000
        if (empty($this->connID)) {
12✔
2001
            $this->initialize();
×
2002
        }
2003

2004
        if (false === ($sql = $this->_listColumns($tableName))) {
12✔
2005
            if ($this->DBDebug) {
×
2006
                throw new DatabaseException('This feature is not available for the database you are using.');
×
2007
            }
2008

2009
            return false;
×
2010
        }
2011

2012
        $query = $this->query($sql);
12✔
2013

2014
        $this->dataCache['field_names'][$table] = [];
12✔
2015

2016
        foreach ($query->getResultArray() as $row) {
12✔
2017
            // Do we know from where to get the column's name?
2018
            if (! isset($key)) {
12✔
2019
                if (isset($row['column_name'])) {
12✔
2020
                    $key = 'column_name';
12✔
2021
                } elseif (isset($row['COLUMN_NAME'])) {
12✔
2022
                    $key = 'COLUMN_NAME';
12✔
2023
                } else {
2024
                    // We have no other choice but to just get the first element's key.
2025
                    $key = key($row);
12✔
2026
                }
2027
            }
2028

2029
            $this->dataCache['field_names'][$table][] = $row[$key];
12✔
2030
        }
2031

2032
        return $this->dataCache['field_names'][$table];
12✔
2033
    }
2034

2035
    /**
2036
     * Determine if a particular field exists
2037
     */
2038
    public function fieldExists(string $fieldName, string $tableName): bool
2039
    {
2040
        return in_array($fieldName, $this->getFieldNames($tableName), true);
8✔
2041
    }
2042

2043
    /**
2044
     * Returns an object with field data
2045
     *
2046
     * @return list<stdClass>
2047
     */
2048
    public function getFieldData(string $table)
2049
    {
2050
        return $this->_fieldData($this->protectIdentifiers($table, true, false, false));
152✔
2051
    }
2052

2053
    /**
2054
     * Returns an object with key data
2055
     *
2056
     * @return array<string, stdClass>
2057
     */
2058
    public function getIndexData(string $table)
2059
    {
2060
        return $this->_indexData($this->protectIdentifiers($table, true, false, false));
165✔
2061
    }
2062

2063
    /**
2064
     * Returns an object with foreign key data
2065
     *
2066
     * @return array<string, stdClass>
2067
     */
2068
    public function getForeignKeyData(string $table)
2069
    {
2070
        return $this->_foreignKeyData($this->protectIdentifiers($table, true, false, false));
37✔
2071
    }
2072

2073
    /**
2074
     * Converts array of arrays generated by _foreignKeyData() to array of objects
2075
     *
2076
     * @return array<string, stdClass>
2077
     *
2078
     * array[
2079
     *    {constraint_name} =>
2080
     *        stdClass[
2081
     *            'constraint_name'     => string,
2082
     *            'table_name'          => string,
2083
     *            'column_name'         => string[],
2084
     *            'foreign_table_name'  => string,
2085
     *            'foreign_column_name' => string[],
2086
     *            'on_delete'           => string,
2087
     *            'on_update'           => string,
2088
     *            'match'               => string
2089
     *        ]
2090
     * ]
2091
     */
2092
    protected function foreignKeyDataToObjects(array $data)
2093
    {
2094
        $retVal = [];
37✔
2095

2096
        foreach ($data as $row) {
37✔
2097
            $name = $row['constraint_name'];
12✔
2098

2099
            // for sqlite generate name
2100
            if ($name === null) {
12✔
2101
                $name = $row['table_name'] . '_' . implode('_', $row['column_name']) . '_foreign';
11✔
2102
            }
2103

2104
            $obj                      = new stdClass();
12✔
2105
            $obj->constraint_name     = $name;
12✔
2106
            $obj->table_name          = $row['table_name'];
12✔
2107
            $obj->column_name         = $row['column_name'];
12✔
2108
            $obj->foreign_table_name  = $row['foreign_table_name'];
12✔
2109
            $obj->foreign_column_name = $row['foreign_column_name'];
12✔
2110
            $obj->on_delete           = $row['on_delete'];
12✔
2111
            $obj->on_update           = $row['on_update'];
12✔
2112
            $obj->match               = $row['match'];
12✔
2113

2114
            $retVal[$name] = $obj;
12✔
2115
        }
2116

2117
        return $retVal;
37✔
2118
    }
2119

2120
    /**
2121
     * Disables foreign key checks temporarily.
2122
     *
2123
     * @return bool
2124
     */
2125
    public function disableForeignKeyChecks()
2126
    {
2127
        $sql = $this->_disableForeignKeyChecks();
841✔
2128

2129
        if ($sql === '') {
841✔
2130
            // The feature is not supported.
2131
            return false;
×
2132
        }
2133

2134
        return $this->query($sql);
841✔
2135
    }
2136

2137
    /**
2138
     * Enables foreign key checks temporarily.
2139
     *
2140
     * @return bool
2141
     */
2142
    public function enableForeignKeyChecks()
2143
    {
2144
        $sql = $this->_enableForeignKeyChecks();
916✔
2145

2146
        if ($sql === '') {
916✔
2147
            // The feature is not supported.
2148
            return false;
×
2149
        }
2150

2151
        return $this->query($sql);
916✔
2152
    }
2153

2154
    /**
2155
     * Allows the engine to be set into a mode where queries are not
2156
     * actually executed, but they are still generated, timed, etc.
2157
     *
2158
     * This is primarily used by the prepared query functionality.
2159
     *
2160
     * @return $this
2161
     */
2162
    public function pretend(bool $pretend = true)
2163
    {
2164
        $this->pretend = $pretend;
18✔
2165

2166
        return $this;
18✔
2167
    }
2168

2169
    /**
2170
     * Empties our data cache. Especially helpful during testing.
2171
     *
2172
     * @return $this
2173
     */
2174
    public function resetDataCache()
2175
    {
2176
        $this->dataCache = [];
46✔
2177

2178
        return $this;
46✔
2179
    }
2180

2181
    /**
2182
     * Determines if the statement is a write-type query or not.
2183
     *
2184
     * @param string $sql
2185
     */
2186
    public function isWriteType($sql): bool
2187
    {
2188
        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);
944✔
2189
    }
2190

2191
    /**
2192
     * Returns the last error code and message.
2193
     *
2194
     * Must return an array with keys 'code' and 'message':
2195
     *
2196
     * @return array{code: int|string|null, message: string|null}
2197
     */
2198
    abstract public function error(): array;
2199

2200
    /**
2201
     * Returns the exception that would have been thrown on the last failed
2202
     * query if DBDebug were enabled. Returns null if the last query succeeded
2203
     * or if DBDebug is true (in which case the exception is always thrown
2204
     * directly and this method will always return null).
2205
     */
2206
    public function getLastException(): ?DatabaseException
2207
    {
2208
        return $this->lastException;
7✔
2209
    }
2210

2211
    /**
2212
     * Sets the exception for the last failed database operation.
2213
     *
2214
     * @internal This method is for internal database component use only.
2215
     */
2216
    public function setLastException(?DatabaseException $exception): void
2217
    {
2218
        $this->lastException = $exception;
19✔
2219
    }
2220

2221
    /**
2222
     * Checks whether the native database error represents a unique constraint violation.
2223
     */
2224
    protected function isUniqueConstraintViolation(int|string $code, string $message): bool
2225
    {
2226
        return false;
7✔
2227
    }
2228

2229
    /**
2230
     * Checks whether the native database code represents a retryable transaction failure.
2231
     */
2232
    protected function isRetryableTransactionErrorCode(int|string $code): bool
2233
    {
2234
        return false;
2✔
2235
    }
2236

2237
    /**
2238
     * Creates the appropriate database exception for a native database error.
2239
     *
2240
     * @internal This method is for internal database component use only.
2241
     */
2242
    public function createDatabaseException(
2243
        string $message,
2244
        int|string $code = 0,
2245
        ?Throwable $previous = null,
2246
    ): DatabaseException {
2247
        if ($this->isUniqueConstraintViolation($code, $message)) {
87✔
2248
            return new UniqueConstraintViolationException($message, $code, $previous);
44✔
2249
        }
2250

2251
        if ($this->isRetryableTransactionErrorCode($code)) {
69✔
2252
            return new RetryableTransactionException($message, $code, $previous);
16✔
2253
        }
2254

2255
        return new DatabaseException($message, $code, $previous);
53✔
2256
    }
2257

2258
    /**
2259
     * Insert ID
2260
     *
2261
     * @return int|string
2262
     */
2263
    abstract public function insertID();
2264

2265
    /**
2266
     * Generates the SQL for listing tables in a platform-dependent manner.
2267
     *
2268
     * @param string|null $tableName If $tableName is provided will return only this table if exists.
2269
     *
2270
     * @return false|string
2271
     */
2272
    abstract protected function _listTables(bool $constrainByPrefix = false, ?string $tableName = null);
2273

2274
    /**
2275
     * Generates a platform-specific query string so that the column names can be fetched.
2276
     *
2277
     * @param string|TableName $table
2278
     *
2279
     * @return false|string
2280
     */
2281
    abstract protected function _listColumns($table = '');
2282

2283
    /**
2284
     * Platform-specific field data information.
2285
     *
2286
     * @see getFieldData()
2287
     *
2288
     * @return list<stdClass>
2289
     */
2290
    abstract protected function _fieldData(string $table): array;
2291

2292
    /**
2293
     * Platform-specific index data.
2294
     *
2295
     * @see    getIndexData()
2296
     *
2297
     * @return array<string, stdClass>
2298
     */
2299
    abstract protected function _indexData(string $table): array;
2300

2301
    /**
2302
     * Platform-specific foreign keys data.
2303
     *
2304
     * @see    getForeignKeyData()
2305
     *
2306
     * @return array<string, stdClass>
2307
     */
2308
    abstract protected function _foreignKeyData(string $table): array;
2309

2310
    /**
2311
     * Platform-specific SQL statement to disable foreign key checks.
2312
     *
2313
     * If this feature is not supported, return empty string.
2314
     *
2315
     * @TODO This method should be moved to an interface that represents foreign key support.
2316
     *
2317
     * @return string
2318
     *
2319
     * @see disableForeignKeyChecks()
2320
     */
2321
    protected function _disableForeignKeyChecks()
2322
    {
2323
        return '';
×
2324
    }
2325

2326
    /**
2327
     * Platform-specific SQL statement to enable foreign key checks.
2328
     *
2329
     * If this feature is not supported, return empty string.
2330
     *
2331
     * @TODO This method should be moved to an interface that represents foreign key support.
2332
     *
2333
     * @return string
2334
     *
2335
     * @see enableForeignKeyChecks()
2336
     */
2337
    protected function _enableForeignKeyChecks()
2338
    {
2339
        return '';
×
2340
    }
2341

2342
    /**
2343
     * Converts a named timezone to an offset string.
2344
     *
2345
     * Converts timezone identifiers (e.g., 'America/New_York') to offset strings
2346
     * (e.g., '-05:00' or '-04:00' depending on DST). This is useful because not all
2347
     * databases have timezone tables loaded, but all support offset notation.
2348
     *
2349
     * @param string $timezone Named timezone (e.g., 'America/New_York', 'UTC', 'Europe/Paris')
2350
     *
2351
     * @return string Offset string (e.g., '+00:00', '-05:00', '+01:00')
2352
     */
2353
    protected function convertTimezoneToOffset(string $timezone): string
2354
    {
2355
        // If it's already an offset, return as-is
2356
        if (preg_match('/^[+-]\d{2}:\d{2}$/', $timezone)) {
9✔
2357
            return $timezone;
3✔
2358
        }
2359

2360
        try {
2361
            $offset = Time::now($timezone)->getOffset();
6✔
2362

2363
            // Convert offset seconds to +-HH:MM format
2364
            $hours   = (int) ($offset / 3600);
5✔
2365
            $minutes = abs((int) (($offset % 3600) / 60));
5✔
2366

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

2372
            return '+00:00';
1✔
2373
        }
2374
    }
2375

2376
    /**
2377
     * Gets the timezone string to use for database session.
2378
     *
2379
     * Handles the timezone configuration logic:
2380
     * - false: Don't set timezone (returns null)
2381
     * - true: Auto-sync with app timezone from config
2382
     * - string: Use specific timezone (converts named timezones to offsets)
2383
     *
2384
     * @return string|null The timezone offset string, or null if timezone should not be set
2385
     */
2386
    protected function getSessionTimezone(): ?string
2387
    {
2388
        if ($this->timezone === false) {
84✔
2389
            return null;
78✔
2390
        }
2391

2392
        // Auto-sync with app timezone
2393
        if ($this->timezone === true) {
6✔
2394
            $appConfig = config('App');
2✔
2395
            $timezone  = $appConfig->appTimezone;
2✔
2396
        } else {
2397
            // Use specific timezone from config
2398
            $timezone = $this->timezone;
4✔
2399
        }
2400

2401
        return $this->convertTimezoneToOffset($timezone);
6✔
2402
    }
2403

2404
    /**
2405
     * Accessor for properties if they exist.
2406
     *
2407
     * @return array|bool|float|int|object|resource|string|null
2408
     */
2409
    public function __get(string $key)
2410
    {
2411
        if (property_exists($this, $key)) {
1,239✔
2412
            return $this->{$key};
1,238✔
2413
        }
2414

2415
        return null;
1✔
2416
    }
2417

2418
    /**
2419
     * Checker for properties existence.
2420
     */
2421
    public function __isset(string $key): bool
2422
    {
2423
        return property_exists($this, $key);
317✔
2424
    }
2425
}
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