From 61dafe9c7fb65ccfd24579c89fe4ef5ef1bedcb9 Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 6 Mar 2026 07:58:00 +0100 Subject: [PATCH 1/4] fix: database properties casting --- system/Database/BaseConnection.php | 103 ++++++++++++++++++- tests/system/Database/BaseConnectionTest.php | 90 ++++++++++++++++ 2 files changed, 191 insertions(+), 2 deletions(-) diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 66070942a11b..f701e59d773c 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -16,6 +16,10 @@ use Closure; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Events\Events; +use ReflectionNamedType; +use ReflectionProperty; +use ReflectionType; +use ReflectionUnionType; use stdClass; use Stringable; use Throwable; @@ -59,6 +63,13 @@ */ abstract class BaseConnection implements ConnectionInterface { + /** + * Cached builtin type names per class/property. + * + * @var array>> + */ + private static array $propertyBuiltinTypesCache = []; + /** * Data Source Name / Connect string * @@ -374,7 +385,7 @@ public function __construct(array $params) foreach ($params as $key => $value) { if (property_exists($this, $key)) { - $this->{$key} = $value; + $this->{$key} = $this->castScalarValueForTypedProperty($key, $value); } } @@ -392,6 +403,94 @@ public function __construct(array $params) } } + /** + * Some config values (especially env overrides without clear source type) + * can still reach us as strings. Coerce them for typed properties to keep + * strict typing compatible. + */ + private function castScalarValueForTypedProperty(string $property, mixed $value): mixed + { + if (! is_string($value)) { + return $value; + } + + $types = $this->getBuiltinPropertyTypes($property); + + if ($types === [] || in_array('string', $types, true) || in_array('mixed', $types, true)) { + return $value; + } + + $trimmedValue = trim($value); + + if (in_array('null', $types, true) && strtolower($trimmedValue) === 'null') { + return null; + } + + if (in_array('int', $types, true) && preg_match('/^[+-]?\d+$/', $trimmedValue) === 1) { + return (int) $trimmedValue; + } + + if (in_array('float', $types, true) && is_numeric($trimmedValue)) { + return (float) $trimmedValue; + } + + if (in_array('bool', $types, true) || in_array('false', $types, true) || in_array('true', $types, true)) { + $boolValue = filter_var($trimmedValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if ($boolValue !== null) { + if (in_array('bool', $types, true)) { + return $boolValue; + } + + if ($boolValue === false && in_array('false', $types, true)) { + return false; + } + + if ($boolValue === true && in_array('true', $types, true)) { + return true; + } + } + } + + return $value; + } + + /** + * @return list + */ + private function getBuiltinPropertyTypes(string $property): array + { + $className = static::class; + + if (isset(self::$propertyBuiltinTypesCache[$className][$property])) { + return self::$propertyBuiltinTypesCache[$className][$property]; + } + + $type = (new ReflectionProperty($className, $property))->getType(); + + if (! $type instanceof ReflectionType) { + return self::$propertyBuiltinTypesCache[$className][$property] = []; + } + + $types = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type]; + + $builtinTypes = []; + + foreach ($types as $namedType) { + if (! $namedType instanceof ReflectionNamedType || ! $namedType->isBuiltin()) { + continue; + } + + $builtinTypes[] = $namedType->getName(); + } + + if ($type->allowsNull() && ! in_array('null', $builtinTypes, true)) { + $builtinTypes[] = 'null'; + } + + return self::$propertyBuiltinTypesCache[$className][$property] = $builtinTypes; + } + /** * Initializes the database connection/settings. * @@ -436,7 +535,7 @@ public function initialize() // Replace the current settings with those of the failover foreach ($failover as $key => $val) { if (property_exists($this, $key)) { - $this->{$key} = $val; + $this->{$key} = $this->castScalarValueForTypedProperty($key, $val); } } diff --git a/tests/system/Database/BaseConnectionTest.php b/tests/system/Database/BaseConnectionTest.php index 0a797c3ac9ca..f567234647cc 100644 --- a/tests/system/Database/BaseConnectionTest.php +++ b/tests/system/Database/BaseConnectionTest.php @@ -19,6 +19,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use Throwable; +use TypeError; /** * @internal @@ -95,6 +96,95 @@ public function testSavesConfigOptions(): void ], $db->dateFormat); } + public function testCastsStringConfigValuesToTypedProperties(): void + { + $db = new class ([ + ...$this->options, + 'synchronous' => '1', + 'typedBool' => '0', + 'nullInt' => 'null', + ]) extends MockConnection { + protected ?int $synchronous = null; + protected bool $typedBool = true; + protected ?int $nullInt = 1; + + public function getSynchronous(): ?int + { + return $this->synchronous; + } + + public function isTypedBool(): bool + { + return $this->typedBool; + } + + public function getNullInt(): ?int + { + return $this->nullInt; + } + }; + + $this->assertSame(1, $db->getSynchronous()); + $this->assertFalse($db->isTypedBool()); + $this->assertNull($db->getNullInt()); + } + + public function testCastsExtendedBoolStringsToBool(): void + { + $db = new class ([ + ...$this->options, + 'enabledYes' => 'yes', + 'enabledOn' => 'on', + 'disabledNo' => 'no', + 'disabledOff' => 'off', + ]) extends MockConnection { + protected bool $enabledYes = false; + protected bool $enabledOn = false; + protected bool $disabledNo = true; + protected bool $disabledOff = true; + + public function isEnabledYes(): bool { return $this->enabledYes; } + public function isEnabledOn(): bool { return $this->enabledOn; } + public function isDisabledNo(): bool { return $this->disabledNo; } + public function isDisabledOff(): bool { return $this->disabledOff; } + }; + + $this->assertTrue($db->isEnabledYes()); + $this->assertTrue($db->isEnabledOn()); + $this->assertFalse($db->isDisabledNo()); + $this->assertFalse($db->isDisabledOff()); + } + + public function testCastsFalseAndTrueStandaloneUnionTypes(): void + { + $db = new class ([ + ...$this->options, + 'withFalse' => 'false', + 'withTrue' => 'true', + ]) extends MockConnection { + protected int|false $withFalse = 0; + protected int|true $withTrue = 0; + + public function getWithFalse(): int|false { return $this->withFalse; } + public function getWithTrue(): int|true { return $this->withTrue; } + }; + + $this->assertFalse($db->getWithFalse()); + $this->assertTrue($db->getWithTrue()); + } + + public function testInvalidStringValueForTypedPropertyThrowsTypeError(): void + { + $this->expectException(TypeError::class); + + new class ([ + ...$this->options, + 'synchronous' => 'not-an-int', + ]) extends MockConnection { + protected ?int $synchronous = null; + }; + } + public function testConnectionThrowExceptionWhenCannotConnect(): void { try { From e36fbd95361b68ccc69a22824b5c291c3f8e5580 Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 13 Mar 2026 19:07:29 +0100 Subject: [PATCH 2/4] refactor --- system/Database/BaseConnection.php | 75 +++++++++++++------- system/Database/SQLite3/Connection.php | 2 +- tests/system/Database/BaseConnectionTest.php | 73 +++++++++++-------- user_guide_src/source/changelogs/v4.7.1.rst | 3 + 4 files changed, 96 insertions(+), 57 deletions(-) diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index f701e59d773c..12259aa9ae92 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -16,8 +16,8 @@ use Closure; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Events\Events; +use ReflectionClass; use ReflectionNamedType; -use ReflectionProperty; use ReflectionType; use ReflectionUnionType; use stdClass; @@ -383,9 +383,14 @@ public function __construct(array $params) unset($params['dateFormat']); } + $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($params)); + foreach ($params as $key => $value) { if (property_exists($this, $key)) { - $this->{$key} = $this->castScalarValueForTypedProperty($key, $value); + $this->{$key} = $this->castScalarValueForTypedProperty( + $value, + $typedPropertyTypes[$key] ?? [], + ); } } @@ -407,15 +412,15 @@ public function __construct(array $params) * Some config values (especially env overrides without clear source type) * can still reach us as strings. Coerce them for typed properties to keep * strict typing compatible. + * + * @param list $types */ - private function castScalarValueForTypedProperty(string $property, mixed $value): mixed + private function castScalarValueForTypedProperty(mixed $value, array $types): mixed { if (! is_string($value)) { return $value; } - $types = $this->getBuiltinPropertyTypes($property); - if ($types === [] || in_array('string', $types, true) || in_array('mixed', $types, true)) { return $value; } @@ -456,39 +461,54 @@ private function castScalarValueForTypedProperty(string $property, mixed $value) } /** - * @return list + * @param list $properties + * + * @return array> */ - private function getBuiltinPropertyTypes(string $property): array + private function getBuiltinPropertyTypesMap(array $properties): array { $className = static::class; - if (isset(self::$propertyBuiltinTypesCache[$className][$property])) { - return self::$propertyBuiltinTypesCache[$className][$property]; - } + if (! isset(self::$propertyBuiltinTypesCache[$className])) { + self::$propertyBuiltinTypesCache[$className] = []; - $type = (new ReflectionProperty($className, $property))->getType(); + $reflection = new ReflectionClass($className); - if (! $type instanceof ReflectionType) { - return self::$propertyBuiltinTypesCache[$className][$property] = []; - } + foreach ($reflection->getProperties() as $property) { + $type = $property->getType(); - $types = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type]; + if (! $type instanceof ReflectionType) { + self::$propertyBuiltinTypesCache[$className][$property->getName()] = []; - $builtinTypes = []; + continue; + } - foreach ($types as $namedType) { - if (! $namedType instanceof ReflectionNamedType || ! $namedType->isBuiltin()) { - continue; - } + $namedTypes = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type]; + $builtinTypes = []; - $builtinTypes[] = $namedType->getName(); + foreach ($namedTypes as $namedType) { + if (! $namedType instanceof ReflectionNamedType || ! $namedType->isBuiltin()) { + continue; + } + + $builtinTypes[] = $namedType->getName(); + } + + if ($type->allowsNull() && ! in_array('null', $builtinTypes, true)) { + $builtinTypes[] = 'null'; + } + + self::$propertyBuiltinTypesCache[$className][$property->getName()] = $builtinTypes; + } } - if ($type->allowsNull() && ! in_array('null', $builtinTypes, true)) { - $builtinTypes[] = 'null'; + $typedProperties = []; + + foreach ($properties as $property) { + $typedProperties[$property] = self::$propertyBuiltinTypesCache[$className][$property] ?? []; } - return self::$propertyBuiltinTypesCache[$className][$property] = $builtinTypes; + return $typedProperties; } /** @@ -532,10 +552,15 @@ public function initialize() if (! empty($this->failover) && is_array($this->failover)) { // Go over all the failovers foreach ($this->failover as $index => $failover) { + $typedPropertyTypes = $this->getBuiltinPropertyTypesMap(array_keys($failover)); + // Replace the current settings with those of the failover foreach ($failover as $key => $val) { if (property_exists($this, $key)) { - $this->{$key} = $this->castScalarValueForTypedProperty($key, $val); + $this->{$key} = $this->castScalarValueForTypedProperty( + $val, + $typedPropertyTypes[$key] ?? [], + ); } } diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 9f015b8e9cb6..4495e68c4418 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -55,7 +55,7 @@ class Connection extends BaseConnection * * @see https://www.php.net/manual/en/sqlite3.busytimeout */ - protected $busyTimeout; + protected ?int $busyTimeout = null; /** * The setting of the "synchronous" flag diff --git a/tests/system/Database/BaseConnectionTest.php b/tests/system/Database/BaseConnectionTest.php index f567234647cc..b2d5466a8c13 100644 --- a/tests/system/Database/BaseConnectionTest.php +++ b/tests/system/Database/BaseConnectionTest.php @@ -98,21 +98,22 @@ public function testSavesConfigOptions(): void public function testCastsStringConfigValuesToTypedProperties(): void { - $db = new class ([ - ...$this->options, - 'synchronous' => '1', - 'typedBool' => '0', - 'nullInt' => 'null', - ]) extends MockConnection { + $db = new class ([...$this->options, 'synchronous' => '1', 'busyTimeout' => '4000', 'typedBool' => '0', 'nullInt' => 'null']) extends MockConnection { protected ?int $synchronous = null; - protected bool $typedBool = true; - protected ?int $nullInt = 1; + protected ?int $busyTimeout = null; + protected bool $typedBool = true; + protected ?int $nullInt = 1; public function getSynchronous(): ?int { return $this->synchronous; } + public function getBusyTimeout(): ?int + { + return $this->busyTimeout; + } + public function isTypedBool(): bool { return $this->typedBool; @@ -125,28 +126,38 @@ public function getNullInt(): ?int }; $this->assertSame(1, $db->getSynchronous()); + $this->assertSame(4000, $db->getBusyTimeout()); $this->assertFalse($db->isTypedBool()); $this->assertNull($db->getNullInt()); } public function testCastsExtendedBoolStringsToBool(): void { - $db = new class ([ - ...$this->options, - 'enabledYes' => 'yes', - 'enabledOn' => 'on', - 'disabledNo' => 'no', - 'disabledOff' => 'off', - ]) extends MockConnection { + $db = new class ([...$this->options, 'enabledYes' => 'yes', 'enabledOn' => 'on', 'disabledNo' => 'no', 'disabledOff' => 'off']) extends MockConnection { protected bool $enabledYes = false; protected bool $enabledOn = false; protected bool $disabledNo = true; protected bool $disabledOff = true; - public function isEnabledYes(): bool { return $this->enabledYes; } - public function isEnabledOn(): bool { return $this->enabledOn; } - public function isDisabledNo(): bool { return $this->disabledNo; } - public function isDisabledOff(): bool { return $this->disabledOff; } + public function isEnabledYes(): bool + { + return $this->enabledYes; + } + + public function isEnabledOn(): bool + { + return $this->enabledOn; + } + + public function isDisabledNo(): bool + { + return $this->disabledNo; + } + + public function isDisabledOff(): bool + { + return $this->disabledOff; + } }; $this->assertTrue($db->isEnabledYes()); @@ -157,16 +168,19 @@ public function isDisabledOff(): bool { return $this->disabledOff; } public function testCastsFalseAndTrueStandaloneUnionTypes(): void { - $db = new class ([ - ...$this->options, - 'withFalse' => 'false', - 'withTrue' => 'true', - ]) extends MockConnection { - protected int|false $withFalse = 0; + $db = new class ([...$this->options, 'withFalse' => 'false', 'withTrue' => 'true']) extends MockConnection { + protected false|int $withFalse = 0; protected int|true $withTrue = 0; - public function getWithFalse(): int|false { return $this->withFalse; } - public function getWithTrue(): int|true { return $this->withTrue; } + public function getWithFalse(): false|int + { + return $this->withFalse; + } + + public function getWithTrue(): int|true + { + return $this->withTrue; + } }; $this->assertFalse($db->getWithFalse()); @@ -177,10 +191,7 @@ public function testInvalidStringValueForTypedPropertyThrowsTypeError(): void { $this->expectException(TypeError::class); - new class ([ - ...$this->options, - 'synchronous' => 'not-an-int', - ]) extends MockConnection { + new class ([...$this->options, 'synchronous' => 'not-an-int']) extends MockConnection { protected ?int $synchronous = null; }; } diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index bd5ef485d17c..c63d257ca895 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -14,6 +14,8 @@ Release Date: Unreleased BREAKING ******** +- **Database:** ``CodeIgniter\Database\SQLite3\Connection::$busyTimeout`` is now typed as ``?int``. Custom subclasses that redeclare this property will need to be updated. + *************** Message Changes *************** @@ -53,6 +55,7 @@ Bugs Fixed - **ContentSecurityPolicy:** Fixed a bug where nonces generated by ``getScriptNonce()`` and ``getStyleNonce()`` were not added to the ``script-src-elem`` and ``style-src-elem`` directives, causing nonces to be silently ignored by browsers when those directives were present. - **Database:** Fixed a bug where ``BaseConnection::callFunction()`` could double-prefix already-prefixed function names. - **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``. +- **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. - **Model:** Fixed a bug where ``BaseModel::updateBatch()`` threw an exception when ``updateOnlyChanged`` was ``true`` and the index field value did not change. - **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. - **Session:** Fixed a bug in ``MemcachedHandler`` where the constructor incorrectly threw an exception when ``savePath`` was not empty. From 23808c559f98c6e0cc6f35b9a08f741359ae365b Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 13 Mar 2026 19:29:18 +0100 Subject: [PATCH 3/4] refactor --- system/Database/BaseConnection.php | 21 +++++++++++++++-- tests/system/Database/BaseConnectionTest.php | 24 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 12259aa9ae92..bd2cb62053dd 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -468,17 +468,29 @@ private function castScalarValueForTypedProperty(mixed $value, array $types): mi private function getBuiltinPropertyTypesMap(array $properties): array { $className = static::class; + $requested = array_fill_keys($properties, true); if (! isset(self::$propertyBuiltinTypesCache[$className])) { self::$propertyBuiltinTypesCache[$className] = []; + } + + // Fill only the properties requested by this call that are not cached yet. + $missing = array_diff_key($requested, self::$propertyBuiltinTypesCache[$className]); + if ($missing !== []) { $reflection = new ReflectionClass($className); foreach ($reflection->getProperties() as $property) { + $propertyName = $property->getName(); + + if (! isset($missing[$propertyName])) { + continue; + } + $type = $property->getType(); if (! $type instanceof ReflectionType) { - self::$propertyBuiltinTypesCache[$className][$property->getName()] = []; + self::$propertyBuiltinTypesCache[$className][$propertyName] = []; continue; } @@ -498,7 +510,12 @@ private function getBuiltinPropertyTypesMap(array $properties): array $builtinTypes[] = 'null'; } - self::$propertyBuiltinTypesCache[$className][$property->getName()] = $builtinTypes; + self::$propertyBuiltinTypesCache[$className][$propertyName] = $builtinTypes; + } + + // Untyped or unresolved properties are cached as empty to avoid re-reflecting them. + foreach (array_keys($missing) as $propertyName) { + self::$propertyBuiltinTypesCache[$className][$propertyName] ??= []; } } diff --git a/tests/system/Database/BaseConnectionTest.php b/tests/system/Database/BaseConnectionTest.php index b2d5466a8c13..50cb52d3a437 100644 --- a/tests/system/Database/BaseConnectionTest.php +++ b/tests/system/Database/BaseConnectionTest.php @@ -187,6 +187,30 @@ public function getWithTrue(): int|true $this->assertTrue($db->getWithTrue()); } + public function testCachesTypedPropertiesIncrementally(): void + { + $factory = fn (array $options) => new class ($options) extends MockConnection { + protected ?int $synchronous = null; + protected ?int $busyTimeout = null; + + public function getSynchronous(): ?int + { + return $this->synchronous; + } + + public function getBusyTimeout(): ?int + { + return $this->busyTimeout; + } + }; + + $first = $factory([...$this->options, 'synchronous' => '1']); + $second = $factory([...$this->options, 'busyTimeout' => '4000']); + + $this->assertSame(1, $first->getSynchronous()); + $this->assertSame(4000, $second->getBusyTimeout()); + } + public function testInvalidStringValueForTypedPropertyThrowsTypeError(): void { $this->expectException(TypeError::class); From 3db8f0634db9b9b77e2d3eb58187df599cd87b3d Mon Sep 17 00:00:00 2001 From: michalsn Date: Fri, 13 Mar 2026 19:32:45 +0100 Subject: [PATCH 4/4] cs fix --- tests/system/Database/BaseConnectionTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/system/Database/BaseConnectionTest.php b/tests/system/Database/BaseConnectionTest.php index 50cb52d3a437..4d6d1f76173d 100644 --- a/tests/system/Database/BaseConnectionTest.php +++ b/tests/system/Database/BaseConnectionTest.php @@ -189,7 +189,7 @@ public function getWithTrue(): int|true public function testCachesTypedPropertiesIncrementally(): void { - $factory = fn (array $options) => new class ($options) extends MockConnection { + $factory = static fn (array $options): MockConnection => new class ($options) extends MockConnection { protected ?int $synchronous = null; protected ?int $busyTimeout = null; @@ -204,7 +204,7 @@ public function getBusyTimeout(): ?int } }; - $first = $factory([...$this->options, 'synchronous' => '1']); + $first = $factory([...$this->options, 'synchronous' => '1']); $second = $factory([...$this->options, 'busyTimeout' => '4000']); $this->assertSame(1, $first->getSynchronous());