Skip to content

Commit 4ab52ef

Browse files
committed
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)
1 parent 27a1e6a commit 4ab52ef

2 files changed

Lines changed: 182 additions & 16 deletions

File tree

‎system/DataCaster/DataCaster.php‎

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ final class DataCaster
4444
*/
4545
private array $types = [];
4646

47+
/**
48+
* Cache of compiled type cast rules.
49+
*
50+
* @var array<string, array{0: class-string<CastInterface|EntityCastInterface>, 1: list<string>}>
51+
*/
52+
private array $compiledTypes = [];
53+
4754
/**
4855
* Convert handlers.
4956
*
@@ -109,7 +116,10 @@ public function __construct(
109116
*/
110117
public function setTypes(array $types): static
111118
{
112-
$this->types = $types;
119+
if ($this->types !== $types) {
120+
$this->types = $types;
121+
$this->compiledTypes = [];
122+
}
113123

114124
return $this;
115125
}
@@ -130,29 +140,46 @@ public function castAs(mixed $value, string $field, string $method = 'get'): mix
130140
}
131141

132142
// If the type is not defined, return as it is.
133-
if (! isset($this->types[$field])) {
143+
$type = $this->types[$field] ?? null;
144+
145+
if ($type === null) {
134146
return $value;
135147
}
136148

137-
$type = $this->types[$field];
149+
if ($value === null) {
150+
if (str_starts_with($type, '?')) {
151+
return null;
152+
}
153+
154+
if ($this->strict) {
155+
throw new InvalidArgumentException(
156+
'Field "' . $field . '" is not nullable, but null was passed.',
157+
);
158+
}
159+
}
160+
161+
if (! isset($this->compiledTypes[$type])) {
162+
$this->compiledTypes[$type] = $this->compileType($field, $type);
163+
}
164+
165+
[$handler, $params] = $this->compiledTypes[$type];
138166

167+
return $handler::$method($value, $params, $this->helper);
168+
}
169+
170+
/**
171+
* Compiles and validates a cast type rule.
172+
*
173+
* @return array{0: class-string<CastInterface|EntityCastInterface>, 1: list<string>} [handler, params]
174+
*/
175+
private function compileType(string $field, string $type): array
176+
{
139177
$isNullable = false;
140178

141179
// Is nullable?
142180
if (str_starts_with($type, '?')) {
143181
$isNullable = true;
144-
145-
if ($value === null) {
146-
return null;
147-
}
148-
149-
$type = substr($type, 1);
150-
} elseif ($value === null) {
151-
if ($this->strict) {
152-
$message = 'Field "' . $field . '" is not nullable, but null was passed.';
153-
154-
throw new InvalidArgumentException($message);
155-
}
182+
$type = substr($type, 1);
156183
}
157184

158185
// In order not to create a separate handler for the
@@ -192,6 +219,6 @@ public function castAs(mixed $value, string $field, string $method = 'get'): mix
192219
throw CastException::forInvalidInterface($handler);
193220
}
194221

195-
return $handler::$method($value, $params, $this->helper);
222+
return [$handler, $params];
196223
}
197224
}

‎tests/system/DataCaster/DataCasterTest.php‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
namespace CodeIgniter\DataCaster;
1515

1616
use CodeIgniter\Entity\Exceptions\CastException;
17+
use CodeIgniter\Exceptions\InvalidArgumentException;
1718
use CodeIgniter\Test\CIUnitTestCase;
1819
use PHPUnit\Framework\Attributes\Group;
20+
use stdClass;
1921

2022
/**
2123
* @internal
@@ -31,4 +33,141 @@ public function testCastInvalidMethodException(): void
3133
$dataCaster = new DataCaster();
3234
$dataCaster->castAs([], 'name', 'add');
3335
}
36+
37+
public function testCastAsWithUndefinedFieldReturnsOriginalValue(): void
38+
{
39+
$dataCaster = new DataCaster();
40+
$value = 'unregistered_field_value';
41+
42+
$this->assertSame($value, $dataCaster->castAs($value, 'undefined_field'));
43+
}
44+
45+
public function testCastAsCachesCompiledTypesAcrossRepeatedCalls(): void
46+
{
47+
$types = [
48+
'id' => 'int',
49+
'is_admin' => 'bool',
50+
'tags' => 'csv',
51+
'options' => 'json[array]',
52+
'bio' => '?string',
53+
];
54+
55+
$dataCaster = new DataCaster(null, $types);
56+
57+
// First call compiles rules
58+
$this->assertSame(42, $dataCaster->castAs('42', 'id'));
59+
$this->assertTrue($dataCaster->castAs('1', 'is_admin'));
60+
$this->assertSame(['news', 'tech'], $dataCaster->castAs('news,tech', 'tags'));
61+
$this->assertSame(['theme' => 'dark'], $dataCaster->castAs('{"theme":"dark"}', 'options'));
62+
63+
// Subsequent calls use compiled cache
64+
$this->assertSame(100, $dataCaster->castAs('100', 'id'));
65+
$this->assertFalse($dataCaster->castAs('0', 'is_admin'));
66+
$this->assertSame(['php'], $dataCaster->castAs('php', 'tags'));
67+
$this->assertSame(['lang' => 'en'], $dataCaster->castAs('{"lang":"en"}', 'options'));
68+
}
69+
70+
public function testCompiledTypeCacheIsSharedByType(): void
71+
{
72+
$dataCaster = new DataCaster(null, [
73+
'first' => 'int',
74+
'second' => 'int',
75+
'third' => 'bool',
76+
]);
77+
78+
$dataCaster->castAs('1', 'first');
79+
$dataCaster->castAs('2', 'second');
80+
$dataCaster->castAs('1', 'third');
81+
82+
$compiledTypes = $this->getPrivateProperty($dataCaster, 'compiledTypes');
83+
84+
$this->assertIsArray($compiledTypes);
85+
$this->assertCount(2, $compiledTypes);
86+
}
87+
88+
public function testCastAsNullableHandling(): void
89+
{
90+
$types = [
91+
'nullable_field' => '?int',
92+
'strict_field' => 'int',
93+
];
94+
95+
$dataCaster = new DataCaster(null, $types, null, true);
96+
97+
$this->assertNull($dataCaster->castAs(null, 'nullable_field'));
98+
$this->assertSame(123, $dataCaster->castAs('123', 'nullable_field'));
99+
100+
$this->expectException(InvalidArgumentException::class);
101+
$this->expectExceptionMessage('Field "strict_field" is not nullable, but null was passed.');
102+
$dataCaster->castAs(null, 'strict_field');
103+
}
104+
105+
public function testCastAsNullableWithUnknownTypeReturnsNull(): void
106+
{
107+
$dataCaster = new DataCaster(null, ['ghost' => '?unknown_handler']);
108+
109+
// Null value for nullable field returns null even if handler is not defined
110+
$this->assertNull($dataCaster->castAs(null, 'ghost'));
111+
112+
// Non-null value triggers missing handler exception
113+
$this->expectException(InvalidArgumentException::class);
114+
$this->expectExceptionMessage('No such handler for "ghost". Invalid type: unknown_handler');
115+
$dataCaster->castAs('foo', 'ghost');
116+
}
117+
118+
public function testCastAsNotNullableStrictThrowsBeforeCheckingHandler(): void
119+
{
120+
$dataCaster = new DataCaster(null, ['ghost' => 'unknown_handler'], null, true);
121+
122+
// Strict mode throws not nullable exception first
123+
$this->expectException(InvalidArgumentException::class);
124+
$this->expectExceptionMessage('Field "ghost" is not nullable, but null was passed.');
125+
$dataCaster->castAs(null, 'ghost');
126+
}
127+
128+
public function testSetTypesInvalidatesCompiledCache(): void
129+
{
130+
$dataCaster = new DataCaster(null, ['data' => 'int']);
131+
132+
$this->assertSame(5, $dataCaster->castAs('5', 'data'));
133+
$this->assertArrayHasKey('int', $this->getPrivateProperty($dataCaster, 'compiledTypes'));
134+
135+
// Change type to csv
136+
$dataCaster->setTypes(['data' => 'csv']);
137+
$this->assertSame([], $this->getPrivateProperty($dataCaster, 'compiledTypes'));
138+
$this->assertSame(['5'], $dataCaster->castAs('5', 'data'));
139+
140+
$compiledTypes = $this->getPrivateProperty($dataCaster, 'compiledTypes');
141+
$this->assertArrayHasKey('csv', $compiledTypes);
142+
143+
// Calling setTypes with identical types preserves cached compilation
144+
$dataCaster->setTypes(['data' => 'csv']);
145+
$this->assertSame($compiledTypes, $this->getPrivateProperty($dataCaster, 'compiledTypes'));
146+
$this->assertSame(['a', 'b'], $dataCaster->castAs('a,b', 'data'));
147+
}
148+
149+
public function testCastAsNoSuchHandlerException(): void
150+
{
151+
$dataCaster = new DataCaster(null, ['name' => 'non_existent_type']);
152+
153+
$this->expectException(InvalidArgumentException::class);
154+
$this->expectExceptionMessage('No such handler for "name". Invalid type: non_existent_type');
155+
156+
$dataCaster->castAs('value', 'name');
157+
}
158+
159+
public function testCastAsInvalidInterfaceExceptionWhenNotStrict(): void
160+
{
161+
/** @phpstan-var mixed $castHandlers */
162+
$castHandlers = [
163+
'invalid' => stdClass::class,
164+
];
165+
166+
$dataCaster = new DataCaster($castHandlers, ['field' => 'invalid'], null, false);
167+
168+
$this->expectException(CastException::class);
169+
$this->expectExceptionMessage(CastException::forInvalidInterface(stdClass::class)->getMessage());
170+
171+
$dataCaster->castAs('value', 'field');
172+
}
34173
}

0 commit comments

Comments
 (0)