From e06f277195dbe7b1b68320fb00929542ffcc5852 Mon Sep 17 00:00:00 2001 From: Adam Dyson Date: Wed, 20 Aug 2025 01:05:59 +1000 Subject: [PATCH 01/10] Irrelevant CS-FIX --- src/Transaction/TupleStorage.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Transaction/TupleStorage.php b/src/Transaction/TupleStorage.php index 0d182949..015e63e6 100644 --- a/src/Transaction/TupleStorage.php +++ b/src/Transaction/TupleStorage.php @@ -4,12 +4,10 @@ namespace Cycle\ORM\Transaction; -use IteratorAggregate; - /** * @internal * - * @implements IteratorAggregate + * @implements \IteratorAggregate */ final class TupleStorage implements \IteratorAggregate, \Countable { From 753a69e898b93e6285f8ca966710beca524c9bc0 Mon Sep 17 00:00:00 2001 From: Adam Dyson Date: Wed, 20 Aug 2025 01:06:35 +1000 Subject: [PATCH 02/10] Adds support for callable with arguments --- src/Parser/Typecast.php | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Parser/Typecast.php b/src/Parser/Typecast.php index f4e6fb32..dc459f74 100644 --- a/src/Parser/Typecast.php +++ b/src/Parser/Typecast.php @@ -14,6 +14,9 @@ final class Typecast implements CastableInterface, UncastableInterface /** @var array */ private array $callableRules = []; + /** @var array */ + private array $callableArguments = []; + /** @var array> */ private array $enumClasses = []; @@ -35,6 +38,20 @@ public function setRules(array $rules): array $this->enumClasses[$key] = (string) $reflection->getBackingType(); $this->rules[$key] = $rule; unset($rules[$key]); + } elseif ( + \is_array($rule) + && \count($rule) >= 2 + && \is_string($rule[0]) + && \is_string($rule[1]) + && \class_exists($rule[0]) + && \method_exists($rule[0], $rule[1]) + ) { + if (isset($rule[2]) && \is_array($rule[2])) { + $this->callableArguments[$key] = $rule[2]; + } + $this->callableRules[$key] = true; + $this->rules[$key] = $rule; + unset($rules[$key]); } elseif (\is_callable($rule)) { $this->callableRules[$key] = true; $this->rules[$key] = $rule; @@ -54,7 +71,8 @@ public function cast(array $data): array } if (isset($this->callableRules[$key])) { - $data[$key] = $rule($data[$key], $this->database); + $arguments = [$data[$key], $this->database, $this->callableArguments[$key] ?? []]; + $data[$key] = $rule(...$arguments); continue; } From 320a7601861f87dd18848d0514ec832d8dbaa772 Mon Sep 17 00:00:00 2001 From: Adam Dyson Date: Wed, 20 Aug 2025 01:07:07 +1000 Subject: [PATCH 03/10] Tests for newly added support for callable with arguments --- tests/ORM/Fixtures/StaticCallableRule.php | 20 ++++++++++++++++++++ tests/ORM/Unit/Parser/TypecastTest.php | 17 +++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tests/ORM/Fixtures/StaticCallableRule.php diff --git a/tests/ORM/Fixtures/StaticCallableRule.php b/tests/ORM/Fixtures/StaticCallableRule.php new file mode 100644 index 00000000..6cb678cf --- /dev/null +++ b/tests/ORM/Fixtures/StaticCallableRule.php @@ -0,0 +1,20 @@ + $value, + 'database' => $database, + 'arguments' => $arguments, + ]; + } +} diff --git a/tests/ORM/Unit/Parser/TypecastTest.php b/tests/ORM/Unit/Parser/TypecastTest.php index 3ca0eeae..ad8efcb0 100644 --- a/tests/ORM/Unit/Parser/TypecastTest.php +++ b/tests/ORM/Unit/Parser/TypecastTest.php @@ -10,6 +10,7 @@ use Cycle\ORM\Tests\Fixtures\Enum\CustomStringable; use Cycle\ORM\Tests\Fixtures\Enum\TypeIntEnum; use Cycle\ORM\Tests\Fixtures\Enum\TypeStringEnum; +use Cycle\ORM\Tests\Fixtures\StaticCallableRule; use Cycle\ORM\Tests\Fixtures\Uuid; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -29,6 +30,7 @@ public function testSetRules(): void 'slug' => fn(string $value) => strtolower($value), 'title' => 'strtoupper', 'test' => [Uuid::class, 'create'], + 'callable' => [StaticCallableRule::class, 'invoke', ['foo' => 'bar']], 'uuid' => 'uuid', 'settings' => 'json', ]; @@ -151,6 +153,21 @@ public function testCastCallableValue(): void $this->assertSame('71ceb213-ec3d-4ae5-911b-ba042abfb204', $result['uuid']->toString()); } + public function testCastCallableWithArguments(): void + { + $this->typecast->setRules(['callable' => [StaticCallableRule::class, 'invoke', ['foo' => 'bar']]]); + + $result = $this->typecast->cast(['callable' => 'baz']); + + $this->assertIsArray($result['callable']); + $this->assertArrayHasKey('value', $result['callable']); + $this->assertArrayHasKey('database', $result['callable']); + $this->assertArrayHasKey('arguments', $result['callable']); + $this->assertSame('baz', $result['callable']['value']); + $this->assertInstanceOf(DatabaseInterface::class, $result['callable']['database']); + $this->assertSame(['foo' => 'bar'], $result['callable']['arguments']); + } + public function testCastJsonValue(): void { $this->typecast->setRules(['foo' => 'json', 'baz' => 'json']); From db5526db2fb7831b4b2e295ce4c3723a7d873b02 Mon Sep 17 00:00:00 2001 From: Adam Dyson Date: Wed, 20 Aug 2025 01:25:55 +1000 Subject: [PATCH 04/10] Updated type hint and psalm baseline --- psalm-baseline.xml | 174 ++-------------------------------------- src/Parser/Typecast.php | 2 +- 2 files changed, 9 insertions(+), 167 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 8f45fee1..3ccef6a0 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + @@ -30,9 +30,6 @@ getContext()]]> - - - @@ -41,9 +38,6 @@ - - - @@ -65,9 +59,6 @@ getContext()]]> - - - @@ -101,9 +92,6 @@ - - - @@ -122,11 +110,6 @@ - - - - - @@ -165,9 +148,6 @@ - - - mapper->cast([$field => $insertID])[$field]]]> @@ -190,12 +170,6 @@ table]]> - - - - - - @@ -208,9 +182,6 @@ - - - @@ -228,13 +199,6 @@ afterExecute, null, static::class)($this->command)]]> beforeExecute, null, static::class)($this->command)]]> - - - - - - - @@ -259,11 +223,6 @@ - - - - - waitScope]]> @@ -299,20 +258,11 @@ getLastError()]]> - - - - - - - - - @@ -516,15 +466,6 @@ getCode()]]> - - - - - - - - - @@ -650,10 +591,6 @@ - - - - @@ -698,9 +635,6 @@ ]]> - - - @@ -764,9 +698,6 @@ discriminator]]> entity]]> - - - @@ -902,10 +833,6 @@ - - - - @@ -963,9 +890,6 @@ initializer, null, $scope === '' ? $class : $scope)($proxy, $properties)]]> - - - @@ -1032,10 +956,6 @@ entityFactory->make($role, $data, $status, $typecast)]]> - - - - @@ -1196,9 +1116,6 @@ lastItemKeys[$index]]]> - - - data[$index]]]> @@ -1283,14 +1200,17 @@ enumClasses]]> + + = 2 + && \is_string($rule[0]) + && \is_string($rule[1])]]> + origin->getValue()]]> - - - @@ -1378,9 +1298,6 @@ resolve($related, true)]]> - - - @@ -1389,9 +1306,6 @@ target]]> - - - @@ -1437,6 +1351,7 @@ + @@ -1600,9 +1515,6 @@ state]]> - - - @@ -1637,9 +1549,6 @@ morphKey]]> - - - @@ -1648,9 +1557,6 @@ morphKey]]> - - - @@ -1666,9 +1572,6 @@ - - - @@ -1685,14 +1588,6 @@ - - - - - - - - @@ -1829,11 +1724,6 @@ - - - - - @@ -2060,11 +1950,6 @@ - - - - - subclasses[$item[self::PARENT]][$role] = &$this->subclasses[$role]]]> @@ -2149,11 +2034,6 @@ getResult()]]> - - - - - @@ -2197,10 +2077,6 @@ - - - - @@ -2253,10 +2129,6 @@ - - - - @@ -2290,9 +2162,6 @@ - - - @@ -2427,9 +2296,6 @@ define(SchemaInterface::TABLE)]]> - - - @@ -2495,10 +2361,6 @@ - - - - @@ -2513,10 +2375,6 @@ - - - - @@ -2774,19 +2632,11 @@ - - - - - - - - @@ -2832,12 +2682,4 @@ - - - - - - - - diff --git a/src/Parser/Typecast.php b/src/Parser/Typecast.php index dc459f74..21585180 100644 --- a/src/Parser/Typecast.php +++ b/src/Parser/Typecast.php @@ -20,7 +20,7 @@ final class Typecast implements CastableInterface, UncastableInterface /** @var array> */ private array $enumClasses = []; - /** @var array|string> */ + /** @var array|string> */ private array $rules = []; public function __construct( From 805e840da3a2b9aa82ec5844d6d3754cecf76e9d Mon Sep 17 00:00:00 2001 From: Adam Dyson Date: Wed, 20 Aug 2025 01:40:42 +1000 Subject: [PATCH 05/10] CI psalm check differs from composer psalm script --- psalm-baseline.xml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 3ccef6a0..3350c0a7 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -245,12 +245,6 @@ waitScope]]> - - - - - - @@ -1186,7 +1180,6 @@ - From 002a4c0255ec50987de5a308545d697a3e3c5589 Mon Sep 17 00:00:00 2001 From: Adam Dyson Date: Wed, 20 Aug 2025 02:23:37 +1000 Subject: [PATCH 06/10] Redefine callable array when args maybe given --- src/Parser/Typecast.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Parser/Typecast.php b/src/Parser/Typecast.php index 21585180..ba169df5 100644 --- a/src/Parser/Typecast.php +++ b/src/Parser/Typecast.php @@ -71,8 +71,10 @@ public function cast(array $data): array } if (isset($this->callableRules[$key])) { + /** @var callable $callable */ + $callable = \is_array($rule) ? [$rule[0], $rule[1]] : $rule; $arguments = [$data[$key], $this->database, $this->callableArguments[$key] ?? []]; - $data[$key] = $rule(...$arguments); + $data[$key] = $callable(...$arguments); continue; } From 4d50868ffb0993424614e8fb1591776a88788ca0 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 20 Aug 2025 13:01:51 +0400 Subject: [PATCH 07/10] refactor(Typecast): prepare closures to better typecast in runtime --- src/Parser/Typecast.php | 183 +++++++++++----------- tests/ORM/Fixtures/StaticCallableRule.php | 27 +++- tests/ORM/Unit/Parser/TypecastTest.php | 38 +++-- 3 files changed, 149 insertions(+), 99 deletions(-) diff --git a/src/Parser/Typecast.php b/src/Parser/Typecast.php index ba169df5..9cdfc665 100644 --- a/src/Parser/Typecast.php +++ b/src/Parser/Typecast.php @@ -4,6 +4,7 @@ namespace Cycle\ORM\Parser; +use BackedEnum; use Cycle\ORM\Exception\TypecastException; use Cycle\Database\DatabaseInterface; @@ -11,51 +12,100 @@ final class Typecast implements CastableInterface, UncastableInterface { private const RULES = ['int', 'bool', 'float', 'datetime', 'json']; - /** @var array */ - private array $callableRules = []; + /** @var array */ + private array $casters = []; - /** @var array */ - private array $callableArguments = []; - - /** @var array> */ - private array $enumClasses = []; - - /** @var array|string> */ - private array $rules = []; + /** @var array */ + private array $uncaters = []; + /** + * @param non-empty-string $role The role of the entity being typecasted + * @param DatabaseInterface $database The database instance used for typecasting + */ public function __construct( + private string $role, private DatabaseInterface $database, ) {} public function setRules(array $rules): array { + $invoker = null; foreach ($rules as $key => $rule) { + // Static rules if (\in_array($rule, self::RULES, true)) { - $this->rules[$key] = $rule; + $this->casters[$key] = match ($rule) { + 'int' => static fn (mixed $value): int => (int) $value, + 'bool' => static fn (mixed $value): bool => (bool) $value, + 'float' => static fn (mixed $value): float => (float) $value, + 'datetime' => fn (mixed $value): \DateTimeImmutable => new \DateTimeImmutable( + $value, + $this->database->getDriver()->getTimezone(), + ), + 'json' => static fn (mixed $value): array => \json_decode( + $value, + true, + 512, + \JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE, + ), + }; + + if ($rule === 'json') { + $this->uncaters[$key] = static fn (mixed $value): string => \json_encode( + $value, + \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE, + ); + } unset($rules[$key]); - } elseif (\is_string($rule) && \is_subclass_of($rule, \BackedEnum::class, true)) { + continue; + } + + // Backed enum rules + if (\is_string($rule) && \is_subclass_of($rule, \BackedEnum::class, true)) { + /** @var class-string<\BackedEnum> $rule */ $reflection = new \ReflectionEnum($rule); - $this->enumClasses[$key] = (string) $reflection->getBackingType(); - $this->rules[$key] = $rule; + $type = (string) $reflection->getBackingType(); + + $this->casters[$key] = $type === 'string' + // String backed enum + ? static fn (mixed $value): ?\BackedEnum => \is_string($value) || \is_numeric($value) + ? $rule::tryFrom((string) $value) + : null + // Int backed enum + : static fn (mixed $value): ?\BackedEnum => \is_int($value) || \is_string($value) && \preg_match('/^\\d++$/', $value) === 1 + ? $rule::tryFrom((int) $value) + : null; + unset($rules[$key]); - } elseif ( - \is_array($rule) - && \count($rule) >= 2 - && \is_string($rule[0]) - && \is_string($rule[1]) - && \class_exists($rule[0]) - && \method_exists($rule[0], $rule[1]) - ) { - if (isset($rule[2]) && \is_array($rule[2])) { - $this->callableArguments[$key] = $rule[2]; - } - $this->callableRules[$key] = true; - $this->rules[$key] = $rule; + continue; + } + + // Callable rules + if (\is_callable($rule)) { + $closure = \Closure::fromCallable($rule); + $this->casters[$key] = (new \ReflectionFunction($closure))->getNumberOfParameters() === 1 + ? $closure + : fn (mixed $value): mixed => $closure($value, $this->database); unset($rules[$key]); - } elseif (\is_callable($rule)) { - $this->callableRules[$key] = true; - $this->rules[$key] = $rule; + continue; + } + + // Callable rules with arguments + if (\is_array($rule) && \array_keys($rule) === [0, 1, 2] && \is_callable([$rule[0], $rule[1]])) { + $closure = \Closure::fromCallable([$rule[0], $rule[1]]); + $args = $rule[2]; + \is_array($args) or throw new \InvalidArgumentException( + "The third argument of typecast rule for the `{$this->role}.{$key}` must be an array of arguments.", + ); unset($rules[$key]); + + // The callable accepts DatabaseInterface as second argument + $reflection = new \ReflectionFunction($closure); + if ($reflection->getParameters()[1]?->getType()->getName() === DatabaseInterface::class) { + $this->casters[$key] = fn(mixed $value): mixed => $closure($value, $this->database, ...$args); + continue; + } + + $this->casters[$key] = static fn(mixed $value): mixed => $closure($value, ...$args); } } @@ -65,39 +115,14 @@ public function setRules(array $rules): array public function cast(array $data): array { try { - foreach ($this->rules as $key => $rule) { - if (!isset($data[$key])) { - continue; + foreach ($this->casters as $key => $callable) { + if (isset($data[$key])) { + $data[$key] = $callable($data[$key]); } - - if (isset($this->callableRules[$key])) { - /** @var callable $callable */ - $callable = \is_array($rule) ? [$rule[0], $rule[1]] : $rule; - $arguments = [$data[$key], $this->database, $this->callableArguments[$key] ?? []]; - $data[$key] = $callable(...$arguments); - continue; - } - - if (isset($this->enumClasses[$key])) { - /** @var class-string<\BackedEnum> $rule */ - $type = $this->enumClasses[$key]; - $value = $data[$key]; - $data[$key] = match (true) { - !\is_scalar($value) => null, - $type === 'string' && (\is_string($type) || \is_numeric($value)) - => $rule::tryFrom((string) $value), - $type === 'int' && (\is_int($value) || \preg_match('/^\\d++$/', $value) === 1) - => $rule::tryFrom((int) $value), - default => null, - }; - continue; - } - - $data[$key] = $this->castPrimitive($rule, $data[$key]); } } catch (\Throwable $e) { throw new TypecastException( - \sprintf('Unable to typecast the `%s` field. %s', $key, $e->getMessage()), + \sprintf('Unable to typecast the `%s.%s` field: %s', $this->role, $key, $e->getMessage()), $e->getCode(), $e, ); @@ -111,38 +136,20 @@ public function cast(array $data): array */ public function uncast(array $data): array { - foreach ($this->rules as $column => $rule) { - if (!isset($data[$column])) { - continue; + try { + foreach ($this->uncaters as $key => $callable) { + if (isset($data[$key])) { + $data[$key] = $callable($data[$key]); + } } - - $data[$column] = match ($rule) { - 'json' => \json_encode( - $data[$column], - \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE, - ), - default => $data[$column], - }; + } catch (\Throwable $e) { + throw new TypecastException( + "Unable to uncast the `{$this->role}.{$key}` field: {$e->getMessage()}", + $e->getCode(), + $e, + ); } return $data; } - - /** - * @throws \Exception - */ - private function castPrimitive(mixed $rule, mixed $value): mixed - { - return match ($rule) { - 'int' => (int) $value, - 'bool' => (bool) $value, - 'float' => (float) $value, - 'datetime' => new \DateTimeImmutable( - $value, - $this->database->getDriver()->getTimezone(), - ), - 'json' => \json_decode($value, true, 512, \JSON_THROW_ON_ERROR), - default => $value, - }; - } } diff --git a/tests/ORM/Fixtures/StaticCallableRule.php b/tests/ORM/Fixtures/StaticCallableRule.php index 6cb678cf..e2abe60a 100644 --- a/tests/ORM/Fixtures/StaticCallableRule.php +++ b/tests/ORM/Fixtures/StaticCallableRule.php @@ -9,12 +9,37 @@ class StaticCallableRule { - public static function invoke(string $value, DatabaseInterface $database, array $arguments): array + public static function invoke(string $value, DatabaseInterface $database, mixed $argument): array { return [ 'value' => $value, 'database' => $database, + 'arguments' => [$argument], + ]; + } + + public static function invokeVariadic(string $value, DatabaseInterface $database, mixed ...$arguments): array + { + return [ + 'value' => $value, + 'database' => $database, + 'arguments' => $arguments, + ]; + } + + public static function invokeWithoutDatabaseVariadic(string $value, mixed ...$arguments): array + { + return [ + 'value' => $value, 'arguments' => $arguments, ]; } + + public static function invokeWithoutDatabase(string $value, mixed $argument): array + { + return [ + 'value' => $value, + 'arguments' => [$argument], + ]; + } } diff --git a/tests/ORM/Unit/Parser/TypecastTest.php b/tests/ORM/Unit/Parser/TypecastTest.php index ad8efcb0..e256274c 100644 --- a/tests/ORM/Unit/Parser/TypecastTest.php +++ b/tests/ORM/Unit/Parser/TypecastTest.php @@ -14,6 +14,7 @@ use Cycle\ORM\Tests\Fixtures\Uuid; use Mockery as m; use PHPUnit\Framework\TestCase; +use ReflectionFunction; class TypecastTest extends TestCase { @@ -153,19 +154,35 @@ public function testCastCallableValue(): void $this->assertSame('71ceb213-ec3d-4ae5-911b-ba042abfb204', $result['uuid']->toString()); } - public function testCastCallableWithArguments(): void + /** + * @dataProvider callablesWithArgs + */ + public function testCastCallableWithArguments(array $callable, array $args, bool $hasDatabase): void { - $this->typecast->setRules(['callable' => [StaticCallableRule::class, 'invoke', ['foo' => 'bar']]]); + $this->typecast->setRules(['callable' => [...$callable, $args]]); - $result = $this->typecast->cast(['callable' => 'baz']); + $result = $this->typecast->cast(['callable' => 'baz'])['callable']; - $this->assertIsArray($result['callable']); - $this->assertArrayHasKey('value', $result['callable']); - $this->assertArrayHasKey('database', $result['callable']); - $this->assertArrayHasKey('arguments', $result['callable']); - $this->assertSame('baz', $result['callable']['value']); - $this->assertInstanceOf(DatabaseInterface::class, $result['callable']['database']); - $this->assertSame(['foo' => 'bar'], $result['callable']['arguments']); + $this->assertSame('baz', $result['value'], 'Value should be "baz"'); + $hasDatabase and $this->assertInstanceOf(DatabaseInterface::class, $result['database'], 'Database passed'); + + $isVariadic = (new ReflectionFunction(\Closure::fromCallable($callable)))->isVariadic(); + $this->assertSame( + $isVariadic ? $args : \array_values($args), + $result['arguments'], + 'Arguments must be the same as passed', + ); + } + + public static function callablesWithArgs(): iterable + { + yield [[StaticCallableRule::class, 'invoke'], ['bar'], true]; + yield [[StaticCallableRule::class, 'invoke'], [['bar']], true]; + yield [[StaticCallableRule::class, 'invoke'], ['argument' => ['bar']], true]; + yield [[StaticCallableRule::class, 'invokeVariadic'], ['foo' => 'bar'], true]; + yield [[StaticCallableRule::class, 'invokeWithoutDatabaseVariadic'], ['foo' => 'bar'], false]; + yield [[StaticCallableRule::class, 'invokeWithoutDatabaseVariadic'], [1, 2, 42], false]; + yield [[StaticCallableRule::class, 'invokeWithoutDatabase'], [69], false]; } public function testCastJsonValue(): void @@ -203,6 +220,7 @@ protected function setUp(): void parent::setUp(); $this->typecast = new Typecast( + 'role', $this->db = m::mock(DatabaseInterface::class), ); } From 7b540b1578dfaff6eeb518992d079eee226dfbf3 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 20 Aug 2025 13:05:20 +0400 Subject: [PATCH 08/10] docs(Typecast): add comment to the class; fix CS; update psalm baseline --- psalm-baseline.xml | 68 +++++++++++++++++++------- src/Parser/Typecast.php | 50 +++++++++++++++---- tests/ORM/Unit/Parser/TypecastTest.php | 25 +++++----- 3 files changed, 103 insertions(+), 40 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 3350c0a7..145bff20 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -245,6 +245,12 @@ waitScope]]> + + + + + + @@ -372,6 +378,12 @@ + + + + + + Repository::class, @@ -460,6 +472,9 @@ getCode()]]> + + + @@ -1165,11 +1180,7 @@ - - $rule::tryFrom((int) $value)]]> - - + getParameters()[1]]]> @@ -1177,28 +1188,51 @@ - - + + casters]]> + casters]]> + casters]]> + casters]]> + casters]]> + + + + + + + + getCode()]]> - + getCode()]]> + - - enumClasses]]> - - - = 2 - && \is_string($rule[0]) - && \is_string($rule[1])]]> - + + getParameters()[1]?->getType()]]> + + + ]]> + diff --git a/src/Parser/Typecast.php b/src/Parser/Typecast.php index 9cdfc665..b21717f0 100644 --- a/src/Parser/Typecast.php +++ b/src/Parser/Typecast.php @@ -8,6 +8,37 @@ use Cycle\ORM\Exception\TypecastException; use Cycle\Database\DatabaseInterface; +/** + * Default typecasting class for ORM entities. + * + * This class handles casting data from database format to PHP types and vice versa. + * + * It supports various rule types including: + * - Built-in primitives: 'int', 'bool', 'float', 'datetime'. + * - JSON encoding/decoding: 'json'. + * - Backed enums: Any class implementing BackedEnum + * - Callable functions: + * - Single-argument static factory: [ClassName::class, 'simple']. + * - Callable with database instance: [ClassName::class, 'withDatabase'] + * - Callable with additional arguments: [ClassName::class, 'customArgs', ['value1', 'value2']]. + * + * ``` + * class ClassName { + * public static function simple(mixed $value): mixed { ... } + * + * public static function withDatabase( + * mixed $value, + * DatabaseInterface $database, // Will be injected automatically + * ): mixed { ... } + * + * public static function customArgs( + * mixed $value, + * string $arg1, // 'value1' will be passed + * string $arg2, // 'value2' will be passed + * ): mixed { ... } + * } + * ``` + */ final class Typecast implements CastableInterface, UncastableInterface { private const RULES = ['int', 'bool', 'float', 'datetime', 'json']; @@ -29,19 +60,18 @@ public function __construct( public function setRules(array $rules): array { - $invoker = null; foreach ($rules as $key => $rule) { // Static rules if (\in_array($rule, self::RULES, true)) { $this->casters[$key] = match ($rule) { - 'int' => static fn (mixed $value): int => (int) $value, - 'bool' => static fn (mixed $value): bool => (bool) $value, - 'float' => static fn (mixed $value): float => (float) $value, - 'datetime' => fn (mixed $value): \DateTimeImmutable => new \DateTimeImmutable( + 'int' => static fn(mixed $value): int => (int) $value, + 'bool' => static fn(mixed $value): bool => (bool) $value, + 'float' => static fn(mixed $value): float => (float) $value, + 'datetime' => fn(mixed $value): \DateTimeImmutable => new \DateTimeImmutable( $value, $this->database->getDriver()->getTimezone(), ), - 'json' => static fn (mixed $value): array => \json_decode( + 'json' => static fn(mixed $value): array => \json_decode( $value, true, 512, @@ -50,7 +80,7 @@ public function setRules(array $rules): array }; if ($rule === 'json') { - $this->uncaters[$key] = static fn (mixed $value): string => \json_encode( + $this->uncaters[$key] = static fn(mixed $value): string => \json_encode( $value, \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE, ); @@ -67,11 +97,11 @@ public function setRules(array $rules): array $this->casters[$key] = $type === 'string' // String backed enum - ? static fn (mixed $value): ?\BackedEnum => \is_string($value) || \is_numeric($value) + ? static fn(mixed $value): ?\BackedEnum => \is_string($value) || \is_numeric($value) ? $rule::tryFrom((string) $value) : null // Int backed enum - : static fn (mixed $value): ?\BackedEnum => \is_int($value) || \is_string($value) && \preg_match('/^\\d++$/', $value) === 1 + : static fn(mixed $value): ?\BackedEnum => \is_int($value) || \is_string($value) && \preg_match('/^\\d++$/', $value) === 1 ? $rule::tryFrom((int) $value) : null; @@ -84,7 +114,7 @@ public function setRules(array $rules): array $closure = \Closure::fromCallable($rule); $this->casters[$key] = (new \ReflectionFunction($closure))->getNumberOfParameters() === 1 ? $closure - : fn (mixed $value): mixed => $closure($value, $this->database); + : fn(mixed $value): mixed => $closure($value, $this->database); unset($rules[$key]); continue; } diff --git a/tests/ORM/Unit/Parser/TypecastTest.php b/tests/ORM/Unit/Parser/TypecastTest.php index e256274c..952be1cc 100644 --- a/tests/ORM/Unit/Parser/TypecastTest.php +++ b/tests/ORM/Unit/Parser/TypecastTest.php @@ -14,13 +14,23 @@ use Cycle\ORM\Tests\Fixtures\Uuid; use Mockery as m; use PHPUnit\Framework\TestCase; -use ReflectionFunction; class TypecastTest extends TestCase { private Typecast $typecast; private m\LegacyMockInterface|m\MockInterface|DatabaseInterface $db; + public static function callablesWithArgs(): iterable + { + yield [[StaticCallableRule::class, 'invoke'], ['bar'], true]; + yield [[StaticCallableRule::class, 'invoke'], [['bar']], true]; + yield [[StaticCallableRule::class, 'invoke'], ['argument' => ['bar']], true]; + yield [[StaticCallableRule::class, 'invokeVariadic'], ['foo' => 'bar'], true]; + yield [[StaticCallableRule::class, 'invokeWithoutDatabaseVariadic'], ['foo' => 'bar'], false]; + yield [[StaticCallableRule::class, 'invokeWithoutDatabaseVariadic'], [1, 2, 42], false]; + yield [[StaticCallableRule::class, 'invokeWithoutDatabase'], [69], false]; + } + public function testSetRules(): void { $rules = [ @@ -166,7 +176,7 @@ public function testCastCallableWithArguments(array $callable, array $args, bool $this->assertSame('baz', $result['value'], 'Value should be "baz"'); $hasDatabase and $this->assertInstanceOf(DatabaseInterface::class, $result['database'], 'Database passed'); - $isVariadic = (new ReflectionFunction(\Closure::fromCallable($callable)))->isVariadic(); + $isVariadic = (new \ReflectionFunction(\Closure::fromCallable($callable)))->isVariadic(); $this->assertSame( $isVariadic ? $args : \array_values($args), $result['arguments'], @@ -174,17 +184,6 @@ public function testCastCallableWithArguments(array $callable, array $args, bool ); } - public static function callablesWithArgs(): iterable - { - yield [[StaticCallableRule::class, 'invoke'], ['bar'], true]; - yield [[StaticCallableRule::class, 'invoke'], [['bar']], true]; - yield [[StaticCallableRule::class, 'invoke'], ['argument' => ['bar']], true]; - yield [[StaticCallableRule::class, 'invokeVariadic'], ['foo' => 'bar'], true]; - yield [[StaticCallableRule::class, 'invokeWithoutDatabaseVariadic'], ['foo' => 'bar'], false]; - yield [[StaticCallableRule::class, 'invokeWithoutDatabaseVariadic'], [1, 2, 42], false]; - yield [[StaticCallableRule::class, 'invokeWithoutDatabase'], [69], false]; - } - public function testCastJsonValue(): void { $this->typecast->setRules(['foo' => 'json', 'baz' => 'json']); From 64e18108716e38eb9de1a7d4fd4749e833080c0f Mon Sep 17 00:00:00 2001 From: Adam Dyson Date: Wed, 20 Aug 2025 23:14:50 +1000 Subject: [PATCH 09/10] Renamed variable --- src/Parser/Typecast.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Parser/Typecast.php b/src/Parser/Typecast.php index b21717f0..c0d5b965 100644 --- a/src/Parser/Typecast.php +++ b/src/Parser/Typecast.php @@ -47,7 +47,7 @@ final class Typecast implements CastableInterface, UncastableInterface private array $casters = []; /** @var array */ - private array $uncaters = []; + private array $uncasters = []; /** * @param non-empty-string $role The role of the entity being typecasted @@ -80,7 +80,7 @@ public function setRules(array $rules): array }; if ($rule === 'json') { - $this->uncaters[$key] = static fn(mixed $value): string => \json_encode( + $this->uncasters[$key] = static fn(mixed $value): string => \json_encode( $value, \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE, ); @@ -167,7 +167,7 @@ public function cast(array $data): array public function uncast(array $data): array { try { - foreach ($this->uncaters as $key => $callable) { + foreach ($this->uncasters as $key => $callable) { if (isset($data[$key])) { $data[$key] = $callable($data[$key]); } From b1dea77320447815b6d0d6d2b7a838cbce38d819 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 20 Aug 2025 17:51:27 +0400 Subject: [PATCH 10/10] fix(Factory): update Typecast instantiation to include role parameter --- src/Factory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Factory.php b/src/Factory.php index d573d0bb..5dc27246 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -90,7 +90,7 @@ public function typecast(SchemaInterface $schema, DatabaseInterface $database, s return $parentHandler; } - $handlers[] = new Typecast($database); + $handlers[] = new Typecast($role, $database); } elseif (\is_array($handler)) { // We need to use composite typecast for array foreach ($handler as $type) { $handlers[] = $this->makeTypecastHandler($type, $database, $schema, $role);