Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 143 additions & 2 deletions system/Database/BaseConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
use Closure;
use CodeIgniter\Database\Exceptions\DatabaseException;
use CodeIgniter\Events\Events;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionType;
use ReflectionUnionType;
use stdClass;
use Stringable;
use Throwable;
Expand Down Expand Up @@ -59,6 +63,13 @@
*/
abstract class BaseConnection implements ConnectionInterface
{
/**
* Cached builtin type names per class/property.
*
* @var array<class-string, array<string, list<string>>>
*/
private static array $propertyBuiltinTypesCache = [];

/**
* Data Source Name / Connect string
*
Expand Down Expand Up @@ -372,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} = $value;
$this->{$key} = $this->castScalarValueForTypedProperty(
$value,
$typedPropertyTypes[$key] ?? [],
);
}
}

Expand All @@ -392,6 +408,126 @@ 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<string> $types
*/
private function castScalarValueForTypedProperty(mixed $value, array $types): mixed
{
if (! is_string($value)) {
return $value;
}

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;
}

/**
* @param list<string> $properties
*
* @return array<string, list<string>>
*/
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][$propertyName] = [];

continue;
}

$namedTypes = $type instanceof ReflectionUnionType ? $type->getTypes() : [$type];
$builtinTypes = [];

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][$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] ??= [];
}
}

$typedProperties = [];

foreach ($properties as $property) {
$typedProperties[$property] = self::$propertyBuiltinTypesCache[$className][$property] ?? [];
}

return $typedProperties;
}

/**
* Initializes the database connection/settings.
*
Expand Down Expand Up @@ -433,10 +569,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} = $val;
$this->{$key} = $this->castScalarValueForTypedProperty(
$val,
$typedPropertyTypes[$key] ?? [],
);
}
}

Expand Down
2 changes: 1 addition & 1 deletion system/Database/SQLite3/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions tests/system/Database/BaseConnectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Throwable;
use TypeError;

/**
* @internal
Expand Down Expand Up @@ -95,6 +96,130 @@ public function testSavesConfigOptions(): void
], $db->dateFormat);
}

public function testCastsStringConfigValuesToTypedProperties(): void
{
$db = new class ([...$this->options, 'synchronous' => '1', 'busyTimeout' => '4000', 'typedBool' => '0', 'nullInt' => 'null']) extends MockConnection {
protected ?int $synchronous = null;
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;
}

public function getNullInt(): ?int
{
return $this->nullInt;
}
};

$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 {
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 false|int $withFalse = 0;
protected int|true $withTrue = 0;

public function getWithFalse(): false|int
{
return $this->withFalse;
}

public function getWithTrue(): int|true
{
return $this->withTrue;
}
};

$this->assertFalse($db->getWithFalse());
$this->assertTrue($db->getWithTrue());
}

public function testCachesTypedPropertiesIncrementally(): void
{
$factory = static fn (array $options): MockConnection => 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);

new class ([...$this->options, 'synchronous' => 'not-an-int']) extends MockConnection {
protected ?int $synchronous = null;
};
}

public function testConnectionThrowExceptionWhenCannotConnect(): void
{
try {
Expand Down
3 changes: 3 additions & 0 deletions user_guide_src/source/changelogs/v4.7.1.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
***************
Expand Down Expand Up @@ -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.
Expand Down
Loading