From 7777bf0cd418083c00042c8d1a222aa41329f205 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 2 Nov 2026 00:11:30 +0100 Subject: [PATCH] perf: cache compiled type cast rules in DataCaster Caches parsed cast rules (handler class, extracted parameters) on demand per unique type in DataCaster. This eliminates repeated regex pattern matching (preg_match), string manipulation (explode, trim), and reflection interface checks (is_subclass_of) for every field on every row hydrated during Entity and Model operations. Benchmark on 900,000 castAs() calls: - Before: ~6,440 ms (7.16 us/call) - After: ~2,943 ms (3.27 us/call) - 2.18x speedup (-54% time, >305k casts/s) --- system/DataCaster/DataCaster.php | 57 ++++++--- tests/system/DataCaster/DataCasterTest.php | 139 +++++++++++++++++++++ 2 files changed, 180 insertions(+), 16 deletions(-) diff --git a/system/DataCaster/DataCaster.php b/system/DataCaster/DataCaster.php index 9d9d3c77e268..db8b1a5c1994 100644 --- a/system/DataCaster/DataCaster.php +++ b/system/DataCaster/DataCaster.php @@ -44,6 +44,13 @@ final class DataCaster */ private array $types = []; + /** + * Cache of compiled type cast rules. + * + * @var array, 1: list}> + */ + private array $compiledTypes = []; + /** * Convert handlers. * @@ -109,7 +116,10 @@ public function __construct( */ public function setTypes(array $types): static { - $this->types = $types; + if ($this->types !== $types) { + $this->types = $types; + $this->compiledTypes = []; + } return $this; } @@ -130,29 +140,44 @@ public function castAs(mixed $value, string $field, string $method = 'get'): mix } // If the type is not defined, return as it is. - if (! isset($this->types[$field])) { + $type = $this->types[$field] ?? null; + + if ($type === null) { return $value; } - $type = $this->types[$field]; + if ($value === null) { + if (str_starts_with($type, '?')) { + return null; + } + if ($this->strict) { + throw new InvalidArgumentException( + 'Field "' . $field . '" is not nullable, but null was passed.', + ); + } + } + + $this->compiledTypes[$type] ??= $this->compileType($field, $type); + + [$handler, $params] = $this->compiledTypes[$type]; + + return $handler::$method($value, $params, $this->helper); + } + + /** + * Compiles and validates a cast type rule. + * + * @return array{0: class-string, 1: list} [handler, params] + */ + private function compileType(string $field, string $type): array + { $isNullable = false; // Is nullable? if (str_starts_with($type, '?')) { $isNullable = true; - - if ($value === null) { - return null; - } - - $type = substr($type, 1); - } elseif ($value === null) { - if ($this->strict) { - $message = 'Field "' . $field . '" is not nullable, but null was passed.'; - - throw new InvalidArgumentException($message); - } + $type = substr($type, 1); } // In order not to create a separate handler for the @@ -192,6 +217,6 @@ public function castAs(mixed $value, string $field, string $method = 'get'): mix throw CastException::forInvalidInterface($handler); } - return $handler::$method($value, $params, $this->helper); + return [$handler, $params]; } } diff --git a/tests/system/DataCaster/DataCasterTest.php b/tests/system/DataCaster/DataCasterTest.php index 89d3cbf40ea3..11d772e714f4 100644 --- a/tests/system/DataCaster/DataCasterTest.php +++ b/tests/system/DataCaster/DataCasterTest.php @@ -14,8 +14,10 @@ namespace CodeIgniter\DataCaster; use CodeIgniter\Entity\Exceptions\CastException; +use CodeIgniter\Exceptions\InvalidArgumentException; use CodeIgniter\Test\CIUnitTestCase; use PHPUnit\Framework\Attributes\Group; +use stdClass; /** * @internal @@ -31,4 +33,141 @@ public function testCastInvalidMethodException(): void $dataCaster = new DataCaster(); $dataCaster->castAs([], 'name', 'add'); } + + public function testCastAsWithUndefinedFieldReturnsOriginalValue(): void + { + $dataCaster = new DataCaster(); + $value = 'unregistered_field_value'; + + $this->assertSame($value, $dataCaster->castAs($value, 'undefined_field')); + } + + public function testCastAsCachesCompiledTypesAcrossRepeatedCalls(): void + { + $types = [ + 'id' => 'int', + 'is_admin' => 'bool', + 'tags' => 'csv', + 'options' => 'json[array]', + 'bio' => '?string', + ]; + + $dataCaster = new DataCaster(null, $types); + + // First call compiles rules + $this->assertSame(42, $dataCaster->castAs('42', 'id')); + $this->assertTrue($dataCaster->castAs('1', 'is_admin')); + $this->assertSame(['news', 'tech'], $dataCaster->castAs('news,tech', 'tags')); + $this->assertSame(['theme' => 'dark'], $dataCaster->castAs('{"theme":"dark"}', 'options')); + + // Subsequent calls use compiled cache + $this->assertSame(100, $dataCaster->castAs('100', 'id')); + $this->assertFalse($dataCaster->castAs('0', 'is_admin')); + $this->assertSame(['php'], $dataCaster->castAs('php', 'tags')); + $this->assertSame(['lang' => 'en'], $dataCaster->castAs('{"lang":"en"}', 'options')); + } + + public function testCompiledTypeCacheIsSharedByType(): void + { + $dataCaster = new DataCaster(null, [ + 'first' => 'int', + 'second' => 'int', + 'third' => 'bool', + ]); + + $dataCaster->castAs('1', 'first'); + $dataCaster->castAs('2', 'second'); + $dataCaster->castAs('1', 'third'); + + $compiledTypes = $this->getPrivateProperty($dataCaster, 'compiledTypes'); + + $this->assertIsArray($compiledTypes); + $this->assertCount(2, $compiledTypes); + } + + public function testCastAsNullableHandling(): void + { + $types = [ + 'nullable_field' => '?int', + 'strict_field' => 'int', + ]; + + $dataCaster = new DataCaster(null, $types, null, true); + + $this->assertNull($dataCaster->castAs(null, 'nullable_field')); + $this->assertSame(123, $dataCaster->castAs('123', 'nullable_field')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Field "strict_field" is not nullable, but null was passed.'); + $dataCaster->castAs(null, 'strict_field'); + } + + public function testCastAsNullableWithUnknownTypeReturnsNull(): void + { + $dataCaster = new DataCaster(null, ['ghost' => '?unknown_handler']); + + // Null value for nullable field returns null even if handler is not defined + $this->assertNull($dataCaster->castAs(null, 'ghost')); + + // Non-null value triggers missing handler exception + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No such handler for "ghost". Invalid type: unknown_handler'); + $dataCaster->castAs('foo', 'ghost'); + } + + public function testCastAsNotNullableStrictThrowsBeforeCheckingHandler(): void + { + $dataCaster = new DataCaster(null, ['ghost' => 'unknown_handler'], null, true); + + // Strict mode throws not nullable exception first + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Field "ghost" is not nullable, but null was passed.'); + $dataCaster->castAs(null, 'ghost'); + } + + public function testSetTypesInvalidatesCompiledCache(): void + { + $dataCaster = new DataCaster(null, ['data' => 'int']); + + $this->assertSame(5, $dataCaster->castAs('5', 'data')); + $this->assertArrayHasKey('int', $this->getPrivateProperty($dataCaster, 'compiledTypes')); + + // Change type to csv + $dataCaster->setTypes(['data' => 'csv']); + $this->assertSame([], $this->getPrivateProperty($dataCaster, 'compiledTypes')); + $this->assertSame(['5'], $dataCaster->castAs('5', 'data')); + + $compiledTypes = $this->getPrivateProperty($dataCaster, 'compiledTypes'); + $this->assertArrayHasKey('csv', $compiledTypes); + + // Calling setTypes with identical types preserves cached compilation + $dataCaster->setTypes(['data' => 'csv']); + $this->assertSame($compiledTypes, $this->getPrivateProperty($dataCaster, 'compiledTypes')); + $this->assertSame(['a', 'b'], $dataCaster->castAs('a,b', 'data')); + } + + public function testCastAsNoSuchHandlerException(): void + { + $dataCaster = new DataCaster(null, ['name' => 'non_existent_type']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No such handler for "name". Invalid type: non_existent_type'); + + $dataCaster->castAs('value', 'name'); + } + + public function testCastAsInvalidInterfaceExceptionWhenNotStrict(): void + { + /** @phpstan-var mixed $castHandlers */ + $castHandlers = [ + 'invalid' => stdClass::class, + ]; + + $dataCaster = new DataCaster($castHandlers, ['field' => 'invalid'], null, false); + + $this->expectException(CastException::class); + $this->expectExceptionMessage(CastException::forInvalidInterface(stdClass::class)->getMessage()); + + $dataCaster->castAs('value', 'field'); + } }