Skip to content

Commit 61dafe9

Browse files
committed
fix: database properties casting
1 parent 0343c06 commit 61dafe9

File tree

2 files changed

+191
-2
lines changed

2 files changed

+191
-2
lines changed

system/Database/BaseConnection.php

Lines changed: 101 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 ReflectionNamedType;
20+
use ReflectionProperty;
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
*
@@ -374,7 +385,7 @@ public function __construct(array $params)
374385

375386
foreach ($params as $key => $value) {
376387
if (property_exists($this, $key)) {
377-
$this->{$key} = $value;
388+
$this->{$key} = $this->castScalarValueForTypedProperty($key, $value);
378389
}
379390
}
380391

@@ -392,6 +403,94 @@ public function __construct(array $params)
392403
}
393404
}
394405

406+
/**
407+
* Some config values (especially env overrides without clear source type)
408+
* can still reach us as strings. Coerce them for typed properties to keep
409+
* strict typing compatible.
410+
*/
411+
private function castScalarValueForTypedProperty(string $property, mixed $value): mixed
412+
{
413+
if (! is_string($value)) {
414+
return $value;
415+
}
416+
417+
$types = $this->getBuiltinPropertyTypes($property);
418+
419+
if ($types === [] || in_array('string', $types, true) || in_array('mixed', $types, true)) {
420+
return $value;
421+
}
422+
423+
$trimmedValue = trim($value);
424+
425+
if (in_array('null', $types, true) && strtolower($trimmedValue) === 'null') {
426+
return null;
427+
}
428+
429+
if (in_array('int', $types, true) && preg_match('/^[+-]?\d+$/', $trimmedValue) === 1) {
430+
return (int) $trimmedValue;
431+
}
432+
433+
if (in_array('float', $types, true) && is_numeric($trimmedValue)) {
434+
return (float) $trimmedValue;
435+
}
436+
437+
if (in_array('bool', $types, true) || in_array('false', $types, true) || in_array('true', $types, true)) {
438+
$boolValue = filter_var($trimmedValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
439+
440+
if ($boolValue !== null) {
441+
if (in_array('bool', $types, true)) {
442+
return $boolValue;
443+
}
444+
445+
if ($boolValue === false && in_array('false', $types, true)) {
446+
return false;
447+
}
448+
449+
if ($boolValue === true && in_array('true', $types, true)) {
450+
return true;
451+
}
452+
}
453+
}
454+
455+
return $value;
456+
}
457+
458+
/**
459+
* @return list<string>
460+
*/
461+
private function getBuiltinPropertyTypes(string $property): array
462+
{
463+
$className = static::class;
464+
465+
if (isset(self::$propertyBuiltinTypesCache[$className][$property])) {
466+
return self::$propertyBuiltinTypesCache[$className][$property];
467+
}
468+
469+
$type = (new ReflectionProperty($className, $property))->getType();
470+
471+
if (! $type instanceof ReflectionType) {
472+
return self::$propertyBuiltinTypesCache[$className][$property] = [];
473+
}
474+
475+
$types = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type];
476+
477+
$builtinTypes = [];
478+
479+
foreach ($types as $namedType) {
480+
if (! $namedType instanceof ReflectionNamedType || ! $namedType->isBuiltin()) {
481+
continue;
482+
}
483+
484+
$builtinTypes[] = $namedType->getName();
485+
}
486+
487+
if ($type->allowsNull() && ! in_array('null', $builtinTypes, true)) {
488+
$builtinTypes[] = 'null';
489+
}
490+
491+
return self::$propertyBuiltinTypesCache[$className][$property] = $builtinTypes;
492+
}
493+
395494
/**
396495
* Initializes the database connection/settings.
397496
*
@@ -436,7 +535,7 @@ public function initialize()
436535
// Replace the current settings with those of the failover
437536
foreach ($failover as $key => $val) {
438537
if (property_exists($this, $key)) {
439-
$this->{$key} = $val;
538+
$this->{$key} = $this->castScalarValueForTypedProperty($key, $val);
440539
}
441540
}
442541

tests/system/Database/BaseConnectionTest.php

Lines changed: 90 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,95 @@ public function testSavesConfigOptions(): void
9596
], $db->dateFormat);
9697
}
9798

99+
public function testCastsStringConfigValuesToTypedProperties(): void
100+
{
101+
$db = new class ([
102+
...$this->options,
103+
'synchronous' => '1',
104+
'typedBool' => '0',
105+
'nullInt' => 'null',
106+
]) extends MockConnection {
107+
protected ?int $synchronous = null;
108+
protected bool $typedBool = true;
109+
protected ?int $nullInt = 1;
110+
111+
public function getSynchronous(): ?int
112+
{
113+
return $this->synchronous;
114+
}
115+
116+
public function isTypedBool(): bool
117+
{
118+
return $this->typedBool;
119+
}
120+
121+
public function getNullInt(): ?int
122+
{
123+
return $this->nullInt;
124+
}
125+
};
126+
127+
$this->assertSame(1, $db->getSynchronous());
128+
$this->assertFalse($db->isTypedBool());
129+
$this->assertNull($db->getNullInt());
130+
}
131+
132+
public function testCastsExtendedBoolStringsToBool(): void
133+
{
134+
$db = new class ([
135+
...$this->options,
136+
'enabledYes' => 'yes',
137+
'enabledOn' => 'on',
138+
'disabledNo' => 'no',
139+
'disabledOff' => 'off',
140+
]) extends MockConnection {
141+
protected bool $enabledYes = false;
142+
protected bool $enabledOn = false;
143+
protected bool $disabledNo = true;
144+
protected bool $disabledOff = true;
145+
146+
public function isEnabledYes(): bool { return $this->enabledYes; }
147+
public function isEnabledOn(): bool { return $this->enabledOn; }
148+
public function isDisabledNo(): bool { return $this->disabledNo; }
149+
public function isDisabledOff(): bool { return $this->disabledOff; }
150+
};
151+
152+
$this->assertTrue($db->isEnabledYes());
153+
$this->assertTrue($db->isEnabledOn());
154+
$this->assertFalse($db->isDisabledNo());
155+
$this->assertFalse($db->isDisabledOff());
156+
}
157+
158+
public function testCastsFalseAndTrueStandaloneUnionTypes(): void
159+
{
160+
$db = new class ([
161+
...$this->options,
162+
'withFalse' => 'false',
163+
'withTrue' => 'true',
164+
]) extends MockConnection {
165+
protected int|false $withFalse = 0;
166+
protected int|true $withTrue = 0;
167+
168+
public function getWithFalse(): int|false { return $this->withFalse; }
169+
public function getWithTrue(): int|true { return $this->withTrue; }
170+
};
171+
172+
$this->assertFalse($db->getWithFalse());
173+
$this->assertTrue($db->getWithTrue());
174+
}
175+
176+
public function testInvalidStringValueForTypedPropertyThrowsTypeError(): void
177+
{
178+
$this->expectException(TypeError::class);
179+
180+
new class ([
181+
...$this->options,
182+
'synchronous' => 'not-an-int',
183+
]) extends MockConnection {
184+
protected ?int $synchronous = null;
185+
};
186+
}
187+
98188
public function testConnectionThrowExceptionWhenCannotConnect(): void
99189
{
100190
try {

0 commit comments

Comments
 (0)