Skip to content

Commit fa57ec7

Browse files
authored
fix: SQLite3 config type handling for .env overrides (#10037)
1 parent 601cfa9 commit fa57ec7

File tree

5 files changed

+284
-3
lines changed

5 files changed

+284
-3
lines changed

system/Database/BaseConnection.php

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
use Closure;
1717
use CodeIgniter\Database\Exceptions\DatabaseException;
1818
use CodeIgniter\Events\Events;
19+
use ReflectionClass;
20+
use ReflectionNamedType;
21+
use ReflectionType;
22+
use ReflectionUnionType;
1923
use stdClass;
2024
use Stringable;
2125
use Throwable;
@@ -59,6 +63,13 @@
5963
*/
6064
abstract class BaseConnection implements ConnectionInterface
6165
{
66+
/**
67+
* Cached builtin type names per class/property.
68+
*
69+
* @var array<class-string, array<string, list<string>>>
70+
*/
71+
private static array $propertyBuiltinTypesCache = [];
72+
6273
/**
6374
* Data Source Name / Connect string
6475
*
@@ -372,9 +383,14 @@ public function __construct(array $params)
372383
unset($params['dateFormat']);
373384
}
374385

386+
$typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($params));
387+
375388
foreach ($params as $key => $value) {
376389
if (property_exists($this, $key)) {
377-
$this->{$key} = $value;
390+
$this->{$key} = $this->castScalarValueForTypedProperty(
391+
$value,
392+
$typedPropertyTypes[$key] ?? [],
393+
);
378394
}
379395
}
380396

@@ -392,6 +408,126 @@ public function __construct(array $params)
392408
}
393409
}
394410

411+
/**
412+
* Some config values (especially env overrides without clear source type)
413+
* can still reach us as strings. Coerce them for typed properties to keep
414+
* strict typing compatible.
415+
*
416+
* @param list<string> $types
417+
*/
418+
private function castScalarValueForTypedProperty(mixed $value, array $types): mixed
419+
{
420+
if (! is_string($value)) {
421+
return $value;
422+
}
423+
424+
if ($types === [] || in_array('string', $types, true) || in_array('mixed', $types, true)) {
425+
return $value;
426+
}
427+
428+
$trimmedValue = trim($value);
429+
430+
if (in_array('null', $types, true) && strtolower($trimmedValue) === 'null') {
431+
return null;
432+
}
433+
434+
if (in_array('int', $types, true) && preg_match('/^[+-]?\d+$/', $trimmedValue) === 1) {
435+
return (int) $trimmedValue;
436+
}
437+
438+
if (in_array('float', $types, true) && is_numeric($trimmedValue)) {
439+
return (float) $trimmedValue;
440+
}
441+
442+
if (in_array('bool', $types, true) || in_array('false', $types, true) || in_array('true', $types, true)) {
443+
$boolValue = filter_var($trimmedValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
444+
445+
if ($boolValue !== null) {
446+
if (in_array('bool', $types, true)) {
447+
return $boolValue;
448+
}
449+
450+
if ($boolValue === false && in_array('false', $types, true)) {
451+
return false;
452+
}
453+
454+
if ($boolValue === true && in_array('true', $types, true)) {
455+
return true;
456+
}
457+
}
458+
}
459+
460+
return $value;
461+
}
462+
463+
/**
464+
* @param list<string> $properties
465+
*
466+
* @return array<string, list<string>>
467+
*/
468+
private function getBuiltinPropertyTypesMap(array $properties): array
469+
{
470+
$className = static::class;
471+
$requested = array_fill_keys($properties, true);
472+
473+
if (! isset(self::$propertyBuiltinTypesCache[$className])) {
474+
self::$propertyBuiltinTypesCache[$className] = [];
475+
}
476+
477+
// Fill only the properties requested by this call that are not cached yet.
478+
$missing = array_diff_key($requested, self::$propertyBuiltinTypesCache[$className]);
479+
480+
if ($missing !== []) {
481+
$reflection = new ReflectionClass($className);
482+
483+
foreach ($reflection->getProperties() as $property) {
484+
$propertyName = $property->getName();
485+
486+
if (! isset($missing[$propertyName])) {
487+
continue;
488+
}
489+
490+
$type = $property->getType();
491+
492+
if (! $type instanceof ReflectionType) {
493+
self::$propertyBuiltinTypesCache[$className][$propertyName] = [];
494+
495+
continue;
496+
}
497+
498+
$namedTypes = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type];
499+
$builtinTypes = [];
500+
501+
foreach ($namedTypes as $namedType) {
502+
if (! $namedType instanceof ReflectionNamedType || ! $namedType->isBuiltin()) {
503+
continue;
504+
}
505+
506+
$builtinTypes[] = $namedType->getName();
507+
}
508+
509+
if ($type->allowsNull() && ! in_array('null', $builtinTypes, true)) {
510+
$builtinTypes[] = 'null';
511+
}
512+
513+
self::$propertyBuiltinTypesCache[$className][$propertyName] = $builtinTypes;
514+
}
515+
516+
// Untyped or unresolved properties are cached as empty to avoid re-reflecting them.
517+
foreach (array_keys($missing) as $propertyName) {
518+
self::$propertyBuiltinTypesCache[$className][$propertyName] ??= [];
519+
}
520+
}
521+
522+
$typedProperties = [];
523+
524+
foreach ($properties as $property) {
525+
$typedProperties[$property] = self::$propertyBuiltinTypesCache[$className][$property] ?? [];
526+
}
527+
528+
return $typedProperties;
529+
}
530+
395531
/**
396532
* Initializes the database connection/settings.
397533
*
@@ -433,10 +569,15 @@ public function initialize()
433569
if (! empty($this->failover) && is_array($this->failover)) {
434570
// Go over all the failovers
435571
foreach ($this->failover as $index => $failover) {
572+
$typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($failover));
573+
436574
// Replace the current settings with those of the failover
437575
foreach ($failover as $key => $val) {
438576
if (property_exists($this, $key)) {
439-
$this->{$key} = $val;
577+
$this->{$key} = $this->castScalarValueForTypedProperty(
578+
$val,
579+
$typedPropertyTypes[$key] ?? [],
580+
);
440581
}
441582
}
442583

system/Database/SQLite3/Connection.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class Connection extends BaseConnection
5555
*
5656
* @see https://www.php.net/manual/en/sqlite3.busytimeout
5757
*/
58-
protected $busyTimeout;
58+
protected ?int $busyTimeout = null;
5959

6060
/**
6161
* The setting of the "synchronous" flag

tests/system/Database/BaseConnectionTest.php

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use PHPUnit\Framework\Attributes\DataProvider;
2020
use PHPUnit\Framework\Attributes\Group;
2121
use Throwable;
22+
use TypeError;
2223

2324
/**
2425
* @internal
@@ -95,6 +96,130 @@ public function testSavesConfigOptions(): void
9596
], $db->dateFormat);
9697
}
9798

99+
public function testCastsStringConfigValuesToTypedProperties(): void
100+
{
101+
$db = new class ([...$this->options, 'synchronous' => '1', 'busyTimeout' => '4000', 'typedBool' => '0', 'nullInt' => 'null']) extends MockConnection {
102+
protected ?int $synchronous = null;
103+
protected ?int $busyTimeout = null;
104+
protected bool $typedBool = true;
105+
protected ?int $nullInt = 1;
106+
107+
public function getSynchronous(): ?int
108+
{
109+
return $this->synchronous;
110+
}
111+
112+
public function getBusyTimeout(): ?int
113+
{
114+
return $this->busyTimeout;
115+
}
116+
117+
public function isTypedBool(): bool
118+
{
119+
return $this->typedBool;
120+
}
121+
122+
public function getNullInt(): ?int
123+
{
124+
return $this->nullInt;
125+
}
126+
};
127+
128+
$this->assertSame(1, $db->getSynchronous());
129+
$this->assertSame(4000, $db->getBusyTimeout());
130+
$this->assertFalse($db->isTypedBool());
131+
$this->assertNull($db->getNullInt());
132+
}
133+
134+
public function testCastsExtendedBoolStringsToBool(): void
135+
{
136+
$db = new class ([...$this->options, 'enabledYes' => 'yes', 'enabledOn' => 'on', 'disabledNo' => 'no', 'disabledOff' => 'off']) extends MockConnection {
137+
protected bool $enabledYes = false;
138+
protected bool $enabledOn = false;
139+
protected bool $disabledNo = true;
140+
protected bool $disabledOff = true;
141+
142+
public function isEnabledYes(): bool
143+
{
144+
return $this->enabledYes;
145+
}
146+
147+
public function isEnabledOn(): bool
148+
{
149+
return $this->enabledOn;
150+
}
151+
152+
public function isDisabledNo(): bool
153+
{
154+
return $this->disabledNo;
155+
}
156+
157+
public function isDisabledOff(): bool
158+
{
159+
return $this->disabledOff;
160+
}
161+
};
162+
163+
$this->assertTrue($db->isEnabledYes());
164+
$this->assertTrue($db->isEnabledOn());
165+
$this->assertFalse($db->isDisabledNo());
166+
$this->assertFalse($db->isDisabledOff());
167+
}
168+
169+
public function testCastsFalseAndTrueStandaloneUnionTypes(): void
170+
{
171+
$db = new class ([...$this->options, 'withFalse' => 'false', 'withTrue' => 'true']) extends MockConnection {
172+
protected false|int $withFalse = 0;
173+
protected int|true $withTrue = 0;
174+
175+
public function getWithFalse(): false|int
176+
{
177+
return $this->withFalse;
178+
}
179+
180+
public function getWithTrue(): int|true
181+
{
182+
return $this->withTrue;
183+
}
184+
};
185+
186+
$this->assertFalse($db->getWithFalse());
187+
$this->assertTrue($db->getWithTrue());
188+
}
189+
190+
public function testCachesTypedPropertiesIncrementally(): void
191+
{
192+
$factory = static fn (array $options): MockConnection => new class ($options) extends MockConnection {
193+
protected ?int $synchronous = null;
194+
protected ?int $busyTimeout = null;
195+
196+
public function getSynchronous(): ?int
197+
{
198+
return $this->synchronous;
199+
}
200+
201+
public function getBusyTimeout(): ?int
202+
{
203+
return $this->busyTimeout;
204+
}
205+
};
206+
207+
$first = $factory([...$this->options, 'synchronous' => '1']);
208+
$second = $factory([...$this->options, 'busyTimeout' => '4000']);
209+
210+
$this->assertSame(1, $first->getSynchronous());
211+
$this->assertSame(4000, $second->getBusyTimeout());
212+
}
213+
214+
public function testInvalidStringValueForTypedPropertyThrowsTypeError(): void
215+
{
216+
$this->expectException(TypeError::class);
217+
218+
new class ([...$this->options, 'synchronous' => 'not-an-int']) extends MockConnection {
219+
protected ?int $synchronous = null;
220+
};
221+
}
222+
98223
public function testConnectionThrowExceptionWhenCannotConnect(): void
99224
{
100225
try {

user_guide_src/source/changelogs/v4.7.1.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ Release Date: Unreleased
1414
BREAKING
1515
********
1616

17+
- **Database:** ``CodeIgniter\Database\SQLite3\Connection::$busyTimeout`` is now typed as ``?int``. Custom subclasses that redeclare this property will need to be updated.
18+
1719
***************
1820
Message Changes
1921
***************
@@ -54,6 +56,7 @@ Bugs Fixed
5456
- **CURLRequest:** Fixed a bug where HTTP/2 responses without a reason phrase (e.g., ``HTTP/2 200``) were not parsed correctly, causing the status code and protocol version to be ignored.
5557
- **Database:** Fixed a bug where ``BaseConnection::callFunction()`` could double-prefix already-prefixed function names.
5658
- **Database:** Fixed a bug where ``BasePreparedQuery::prepare()`` could mis-handle SQL containing colon syntax by over-broad named-placeholder replacement. It now preserves PostgreSQL cast syntax like ``::timestamp``.
59+
- **Database:** Fixed a bug where string values from config arrays (including ``.env`` overrides) were not normalized for typed connection properties, which could cause SQLite3 options like ``synchronous`` and ``busyTimeout`` to be assigned with the wrong type.
5760
- **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change.
5861
- **Model:** Fixed a bug where ``Model::chunk()`` ran an unnecessary extra database query at the end of iteration. ``chunk()`` now also throws ``InvalidArgumentException`` when called with a non-positive chunk size.
5962
- **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty.

user_guide_src/source/installation/upgrade_471.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ Breaking Changes
3434
Breaking Enhancements
3535
*********************
3636

37+
Database Connection Property Casting
38+
======================================
39+
40+
``BaseConnection`` now casts string values coming from ``.env`` overrides to match
41+
the declared type of each connection property. This affects properties that are
42+
``null`` in the config array and then set via ``.env`` - such as SQLite3's
43+
``synchronous`` or ``busyTimeout`` - which previously arrived as strings and were
44+
stored without conversion.
45+
46+
If you extended the SQLite3 handler, review your custom typed properties and update
47+
them if needed.
48+
3749
*************
3850
Project Files
3951
*************

0 commit comments

Comments
 (0)