diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 8f45fee1..145bff20 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()]]> - - - - - - - - - @@ -428,6 +378,12 @@ + + + + + + Repository::class, @@ -516,15 +472,9 @@ getCode()]]> - - - - - - - - - + + + @@ -650,10 +600,6 @@ - - - - @@ -698,9 +644,6 @@ ]]> - - - @@ -764,9 +707,6 @@ discriminator]]> entity]]> - - - @@ -902,10 +842,6 @@ - - - - @@ -963,9 +899,6 @@ initializer, null, $scope === '' ? $class : $scope)($proxy, $properties)]]> - - - @@ -1032,10 +965,6 @@ entityFactory->make($role, $data, $status, $typecast)]]> - - - - @@ -1196,9 +1125,6 @@ lastItemKeys[$index]]]> - - - data[$index]]]> @@ -1254,11 +1180,7 @@ - - $rule::tryFrom((int) $value)]]> - - + getParameters()[1]]]> @@ -1266,31 +1188,56 @@ - - - + + casters]]> + casters]]> + casters]]> + casters]]> + casters]]> + + + + + + + + getCode()]]> - + getCode()]]> + - - enumClasses]]> - + + getParameters()[1]?->getType()]]> + + + ]]> + origin->getValue()]]> - - - @@ -1378,9 +1325,6 @@ resolve($related, true)]]> - - - @@ -1389,9 +1333,6 @@ target]]> - - - @@ -1437,6 +1378,7 @@ + @@ -1600,9 +1542,6 @@ state]]> - - - @@ -1637,9 +1576,6 @@ morphKey]]> - - - @@ -1648,9 +1584,6 @@ morphKey]]> - - - @@ -1666,9 +1599,6 @@ - - - @@ -1685,14 +1615,6 @@ - - - - - - - - @@ -1829,11 +1751,6 @@ - - - - - @@ -2060,11 +1977,6 @@ - - - - - subclasses[$item[self::PARENT]][$role] = &$this->subclasses[$role]]]> @@ -2149,11 +2061,6 @@ getResult()]]> - - - - - @@ -2197,10 +2104,6 @@ - - - - @@ -2253,10 +2156,6 @@ - - - - @@ -2290,9 +2189,6 @@ - - - @@ -2427,9 +2323,6 @@ define(SchemaInterface::TABLE)]]> - - - @@ -2495,10 +2388,6 @@ - - - - @@ -2513,10 +2402,6 @@ - - - - @@ -2774,19 +2659,11 @@ - - - - - - - - @@ -2832,12 +2709,4 @@ - - - - - - - - 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); diff --git a/src/Parser/Typecast.php b/src/Parser/Typecast.php index f4e6fb32..c0d5b965 100644 --- a/src/Parser/Typecast.php +++ b/src/Parser/Typecast.php @@ -4,41 +4,138 @@ namespace Cycle\ORM\Parser; +use BackedEnum; 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']; - /** @var array */ - private array $callableRules = []; + /** @var array */ + private array $casters = []; - /** @var array> */ - private array $enumClasses = []; - - /** @var array|string> */ - private array $rules = []; + /** @var array */ + private array $uncasters = []; + /** + * @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 { 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->uncasters[$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_callable($rule)) { - $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]); + 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); } } @@ -48,36 +145,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])) { - $data[$key] = $rule($data[$key], $this->database); - 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, ); @@ -91,38 +166,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->uncasters 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/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 { diff --git a/tests/ORM/Fixtures/StaticCallableRule.php b/tests/ORM/Fixtures/StaticCallableRule.php new file mode 100644 index 00000000..e2abe60a --- /dev/null +++ b/tests/ORM/Fixtures/StaticCallableRule.php @@ -0,0 +1,45 @@ + $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 3ca0eeae..952be1cc 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; @@ -19,6 +20,17 @@ 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 = [ @@ -29,6 +41,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 +164,26 @@ public function testCastCallableValue(): void $this->assertSame('71ceb213-ec3d-4ae5-911b-ba042abfb204', $result['uuid']->toString()); } + /** + * @dataProvider callablesWithArgs + */ + public function testCastCallableWithArguments(array $callable, array $args, bool $hasDatabase): void + { + $this->typecast->setRules(['callable' => [...$callable, $args]]); + + $result = $this->typecast->cast(['callable' => 'baz'])['callable']; + + $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 function testCastJsonValue(): void { $this->typecast->setRules(['foo' => 'json', 'baz' => 'json']); @@ -186,6 +219,7 @@ protected function setUp(): void parent::setUp(); $this->typecast = new Typecast( + 'role', $this->db = m::mock(DatabaseInterface::class), ); }