From 728c55653821e32e81f836dfba5fcf67a2be803e Mon Sep 17 00:00:00 2001 From: Anatoly Nekhay Date: Fri, 29 Sep 2023 09:54:53 +0200 Subject: [PATCH 1/2] v3.1.0 --- README.md | 30 +- composer.json | 5 +- src/Annotation/Alias.php | 2 + src/Annotation/Format.php | 2 + src/Annotation/Relationship.php | 30 +- src/Annotation/Subtype.php | 61 ++ src/AnnotationReader.php | 60 ++ src/AnnotationReaderAwareInterface.php | 30 + src/AnnotationReaderInterface.php | 36 + src/Dictionary/BuiltinType.php | 28 + src/Dictionary/ErrorCode.php | 4 +- src/DoctrineAnnotationReader.php | 81 ++ src/Exception/InvalidValueException.php | 72 +- .../UnsupportedPropertyTypeException.php | 36 + src/Hydrator.php | 762 ++++-------------- src/HydratorAwareInterface.php | 30 + src/HydratorInterface.php | 50 +- src/Type.php | 88 ++ src/TypeConverter/ArrayTypeConverter.php | 146 ++++ src/TypeConverter/BackedEnumTypeConverter.php | 105 +++ src/TypeConverter/BoolTypeConverter.php | 75 ++ src/TypeConverter/FloatTypeConverter.php | 80 ++ src/TypeConverter/IntTypeConverter.php | 76 ++ .../RelationshipTypeConverter.php | 84 ++ src/TypeConverter/StringTypeConverter.php | 53 ++ src/TypeConverter/TimestampTypeConverter.php | 123 +++ src/TypeConverter/TimezoneTypeConverter.php | 75 ++ src/TypeConverter/UidTypeConverter.php | 76 ++ src/TypeConverterInterface.php | 47 ++ tests/Fixtures/Collection.php | 37 + tests/Fixtures/ObjectWithCollection.php | 10 + .../Fixtures/ObjectWithNullableCollection.php | 10 + tests/Fixtures/ObjectWithNullableTimezone.php | 12 + tests/Fixtures/ObjectWithNullableUid.php | 12 + .../Fixtures/ObjectWithOptionalCollection.php | 10 + tests/Fixtures/ObjectWithOptionalTimezone.php | 12 + tests/Fixtures/ObjectWithOptionalUid.php | 12 + tests/Fixtures/ObjectWithTimezone.php | 12 + tests/Fixtures/ObjectWithUid.php | 12 + tests/HydratorTest.php | 334 +++++++- 40 files changed, 2111 insertions(+), 709 deletions(-) create mode 100644 src/Annotation/Subtype.php create mode 100644 src/AnnotationReader.php create mode 100644 src/AnnotationReaderAwareInterface.php create mode 100644 src/AnnotationReaderInterface.php create mode 100644 src/Dictionary/BuiltinType.php create mode 100644 src/DoctrineAnnotationReader.php create mode 100644 src/HydratorAwareInterface.php create mode 100644 src/Type.php create mode 100644 src/TypeConverter/ArrayTypeConverter.php create mode 100644 src/TypeConverter/BackedEnumTypeConverter.php create mode 100644 src/TypeConverter/BoolTypeConverter.php create mode 100644 src/TypeConverter/FloatTypeConverter.php create mode 100644 src/TypeConverter/IntTypeConverter.php create mode 100644 src/TypeConverter/RelationshipTypeConverter.php create mode 100644 src/TypeConverter/StringTypeConverter.php create mode 100644 src/TypeConverter/TimestampTypeConverter.php create mode 100644 src/TypeConverter/TimezoneTypeConverter.php create mode 100644 src/TypeConverter/UidTypeConverter.php create mode 100644 src/TypeConverterInterface.php create mode 100644 tests/Fixtures/Collection.php create mode 100644 tests/Fixtures/ObjectWithCollection.php create mode 100644 tests/Fixtures/ObjectWithNullableCollection.php create mode 100644 tests/Fixtures/ObjectWithNullableTimezone.php create mode 100644 tests/Fixtures/ObjectWithNullableUid.php create mode 100644 tests/Fixtures/ObjectWithOptionalCollection.php create mode 100644 tests/Fixtures/ObjectWithOptionalTimezone.php create mode 100644 tests/Fixtures/ObjectWithOptionalUid.php create mode 100644 tests/Fixtures/ObjectWithTimezone.php create mode 100644 tests/Fixtures/ObjectWithUid.php diff --git a/README.md b/README.md index 137174f..aedb4fc 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ final class Product public function __construct( public readonly string $name, public readonly Category $category, - #[\Sunrise\Hydrator\Annotation\Relationship(Tag::class, limit: 100)] + #[\Sunrise\Hydrator\Annotation\Subtype(Tag::class, limit: 100)] public readonly array $tags, public readonly Status $status = Status::DISABLED, #[\Sunrise\Hydrator\Annotation\Format(\DATE_RFC3339)] @@ -207,17 +207,41 @@ public readonly array $value; By default, this property accepts an array with any data. However, it can also be used to store relationships by using a special annotation, as shown in the example below: ```php -#[\Sunrise\Hydrator\Annotation\Relationship(SomeDto::class)] +#[\Sunrise\Hydrator\Annotation\Subtype(SomeDto::class)] public readonly array $value; ``` Having an unlimited number of relationships in an array is a potentially bad idea as it can lead to memory leaks. To avoid this, it is recommended to limit such an array, as shown in the example below: ```php -#[\Sunrise\Hydrator\Annotation\Relationship(SomeDto::class, limit: 100)] +#[\Sunrise\Hydrator\Annotation\Subtype(SomeDto::class, limit: 100)] public readonly array $value; ``` +In addition to arrays, you can use collections, i.e. objects implementing the [ArrayAccess](http://php.net/ArrayAccess) interface, for example: + +```php +final class TagCollection implements ArrayAccess +{ + // some code... +} +``` + +```php +final class CreateProductDto +{ + public readonly TagCollection $tags; +} +``` + +Additionally, you can type the elements of such an array or collection, like this: + +```php +#[\Sunrise\Hydrator\Annotation\Subtype(DateTimeImmutable::class, limit: 100)] +#[\Sunrise\Hydrator\Annotation\Format('Y-m-d H:i:s')] +public readonly array $tags; +``` + This property has no any additional behavior and only accepts arrays. ### Timestamp diff --git a/composer.json b/composer.json index fa220db..8a8b11a 100644 --- a/composer.json +++ b/composer.json @@ -26,10 +26,11 @@ "require-dev": { "sunrise/coding-standard": "~1.0.0", "phpunit/phpunit": "^9.6", - "vimeo/psalm": "^5.12", + "vimeo/psalm": "^5.15", "phpstan/phpstan": "^1.10", "doctrine/annotations": "^2.0", - "symfony/validator": "^5.4" + "symfony/validator": "^5.4", + "symfony/uid": "^5.4" }, "autoload": { "psr-4": { diff --git a/src/Annotation/Alias.php b/src/Annotation/Alias.php index 18632cd..6d459c4 100644 --- a/src/Annotation/Alias.php +++ b/src/Annotation/Alias.php @@ -32,6 +32,8 @@ final class Alias * The attribute value * * @var non-empty-string + * + * @readonly */ public string $value; diff --git a/src/Annotation/Format.php b/src/Annotation/Format.php index 407c024..940b52e 100644 --- a/src/Annotation/Format.php +++ b/src/Annotation/Format.php @@ -34,6 +34,8 @@ final class Format * The attribute value * * @var non-empty-string + * + * @readonly */ public string $value; diff --git a/src/Annotation/Relationship.php b/src/Annotation/Relationship.php index 95dd8e9..926175e 100644 --- a/src/Annotation/Relationship.php +++ b/src/Annotation/Relationship.php @@ -21,35 +21,17 @@ * @NamedArgumentConstructor * * @Attributes({ - * @Attribute("target", type="string", required=true), + * @Attribute("name", type="string", required=true), * @Attribute("limit", type="integer", required=false), * }) * * @since 3.0.0 + * + * @deprecated 3.1.0 Use the {@see Subtype} annotation. + * + * @psalm-suppress InvalidExtendClass */ #[Attribute(Attribute::TARGET_PROPERTY)] -final class Relationship +final class Relationship extends Subtype { - - /** - * @var class-string - */ - public string $target; - - /** - * @var int<1, max>|null - */ - public ?int $limit; - - /** - * Constructor of the class - * - * @param class-string $target - * @param int<1, max>|null $limit - */ - public function __construct(string $target, ?int $limit = null) - { - $this->target = $target; - $this->limit = $limit; - } } diff --git a/src/Annotation/Subtype.php b/src/Annotation/Subtype.php new file mode 100644 index 0000000..86f2ae4 --- /dev/null +++ b/src/Annotation/Subtype.php @@ -0,0 +1,61 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\Annotation; + +use Attribute; + +/** + * @Annotation + * @Target({"PROPERTY"}) + * @NamedArgumentConstructor + * + * @Attributes({ + * @Attribute("name", type="string", required=true), + * @Attribute("limit", type="integer", required=false), + * }) + * + * @final See the {@see Relationship} class. + * + * @since 3.1.0 + */ +#[Attribute(Attribute::TARGET_PROPERTY)] +class Subtype +{ + + /** + * @var non-empty-string + * + * @readonly + */ + public string $name; + + /** + * @var int<0, max>|null + * + * @readonly + */ + public ?int $limit; + + /** + * Constructor of the class + * + * @param non-empty-string $name + * @param int<0, max>|null $limit + */ + public function __construct(string $name, ?int $limit = null) + { + $this->name = $name; + $this->limit = $limit; + } +} diff --git a/src/AnnotationReader.php b/src/AnnotationReader.php new file mode 100644 index 0000000..3b946b8 --- /dev/null +++ b/src/AnnotationReader.php @@ -0,0 +1,60 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator; + +use Generator; +use LogicException; +use ReflectionAttribute; +use ReflectionProperty; + +use function sprintf; + +use const PHP_MAJOR_VERSION; + +/** + * @since 3.1.0 + */ +final class AnnotationReader implements AnnotationReaderInterface +{ + + /** + * Constructor of the class + * + * @throws LogicException If PHP version less than 8.0. + */ + public function __construct() + { + if (PHP_MAJOR_VERSION < 8) { + throw new LogicException(sprintf( + 'The annotation reader {%s} requires PHP version greater than or equal to 8.0.', + __CLASS__, + )); + } + } + + /** + * @inheritDoc + */ + public function getAnnotations(ReflectionProperty $target, string $name): Generator + { + if (PHP_MAJOR_VERSION < 8) { + return; + } + + $attributes = $target->getAttributes($name, ReflectionAttribute::IS_INSTANCEOF); + foreach ($attributes as $attribute) { + yield $attribute->newInstance(); + } + } +} diff --git a/src/AnnotationReaderAwareInterface.php b/src/AnnotationReaderAwareInterface.php new file mode 100644 index 0000000..909fa2d --- /dev/null +++ b/src/AnnotationReaderAwareInterface.php @@ -0,0 +1,30 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator; + +/** + * @since 3.1.0 + */ +interface AnnotationReaderAwareInterface +{ + + /** + * Sets the given annotation reader + * + * @param AnnotationReaderInterface $annotationReader + * + * @return void + */ + public function setAnnotationReader(AnnotationReaderInterface $annotationReader): void; +} diff --git a/src/AnnotationReaderInterface.php b/src/AnnotationReaderInterface.php new file mode 100644 index 0000000..4a05f93 --- /dev/null +++ b/src/AnnotationReaderInterface.php @@ -0,0 +1,36 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator; + +use Generator; +use ReflectionProperty; + +/** + * @since 3.1.0 + */ +interface AnnotationReaderInterface +{ + + /** + * Gets annotations from the given target by the given annotation name + * + * @param ReflectionProperty $target + * @param class-string $name + * + * @return Generator + * + * @template T of object + */ + public function getAnnotations(ReflectionProperty $target, string $name): Generator; +} diff --git a/src/Dictionary/BuiltinType.php b/src/Dictionary/BuiltinType.php new file mode 100644 index 0000000..b46edd4 --- /dev/null +++ b/src/Dictionary/BuiltinType.php @@ -0,0 +1,28 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\Dictionary; + +/** + * Built-in types + * + * @since 3.1.0 + */ +final class BuiltinType +{ + public const BOOL = 'bool'; + public const INT = 'int'; + public const FLOAT = 'float'; + public const STRING = 'string'; + public const ARRAY = 'array'; +} diff --git a/src/Dictionary/ErrorCode.php b/src/Dictionary/ErrorCode.php index 018539e..922dd68 100644 --- a/src/Dictionary/ErrorCode.php +++ b/src/Dictionary/ErrorCode.php @@ -27,7 +27,9 @@ final class ErrorCode public const VALUE_SHOULD_BE_NUMBER = 'b30f9ed7-8d8d-451e-9a04-86794c2a0720'; public const VALUE_SHOULD_BE_STRING = 'c84a6c6c-19d1-49a2-a74e-daea88eeea52'; public const VALUE_SHOULD_BE_ARRAY = 'b171342e-de67-409b-9edc-8ccbdf36f2af'; - public const INVALID_TIMESTAMP = 'b0a14918-9e20-470d-8ba3-3d85953ddbce'; public const INVALID_CHOICE = 'e5bd8e3f-60a0-4066-b89b-ef5a186f2836'; + public const INVALID_TIMESTAMP = 'b0a14918-9e20-470d-8ba3-3d85953ddbce'; + public const INVALID_TIMEZONE = '14249d2e-ddbb-4cb0-9e86-8dc2b46b9313'; + public const INVALID_UID = '55b9ba29-57d0-4fd9-988d-678a3b8e819c'; public const REDUNDANT_ELEMENT = '917e1646-b996-4f34-a4f2-1c075bb6e715'; } diff --git a/src/DoctrineAnnotationReader.php b/src/DoctrineAnnotationReader.php new file mode 100644 index 0000000..a727ee0 --- /dev/null +++ b/src/DoctrineAnnotationReader.php @@ -0,0 +1,81 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator; + +use Doctrine\Common\Annotations\AnnotationReader; +use Doctrine\Common\Annotations\Reader; +use Generator; +use LogicException; +use ReflectionProperty; + +use function class_exists; +use function sprintf; + +/** + * @link https://github.com/doctrine/annotations + * + * @since 3.1.0 + */ +final class DoctrineAnnotationReader implements AnnotationReaderInterface +{ + + /** + * @var Reader + */ + private Reader $reader; + + /** + * Constructor of the class + * + * @param Reader $reader + */ + public function __construct(Reader $reader) + { + $this->reader = $reader; + } + + /** + * Creates the class instance with the doctrine's default annotation reader + * + * @return self + * + * @throws LogicException If the doctrine/annotations package isn't installed on the server. + */ + public static function default(): self + { + // @codeCoverageIgnoreStart + if (!class_exists(AnnotationReader::class)) { + throw new LogicException(sprintf( + 'The annotation reader {%s} requires the package doctrine/annotations, ' . + 'run the command `composer require doctrine/annotations` to resolve it.', + __CLASS__, + )); + } // @codeCoverageIgnoreEnd + + return new self(new AnnotationReader()); + } + + /** + * @inheritDoc + */ + public function getAnnotations(ReflectionProperty $target, string $name): Generator + { + $annotations = $this->reader->getPropertyAnnotations($target); + foreach ($annotations as $annotation) { + if ($annotation instanceof $name) { + yield $annotation; + } + } + } +} diff --git a/src/Exception/InvalidValueException.php b/src/Exception/InvalidValueException.php index 768bfdd..89c62e1 100644 --- a/src/Exception/InvalidValueException.php +++ b/src/Exception/InvalidValueException.php @@ -27,14 +27,14 @@ class InvalidValueException extends RuntimeException implements ExceptionInterfa { /** - * @var list + * @var string */ - private array $propertyPath; + private string $errorCode; /** - * @var string + * @var list */ - private string $errorCode; + private array $propertyPath; /** * Constructor of the class @@ -54,17 +54,17 @@ public function __construct(string $message, string $errorCode, array $propertyP /** * @return string */ - final public function getPropertyPath(): string + final public function getErrorCode(): string { - return join('.', $this->propertyPath); + return $this->errorCode; } /** * @return string */ - final public function getErrorCode(): string + final public function getPropertyPath(): string { - return $this->errorCode; + return join('.', $this->propertyPath); } /** @@ -167,14 +167,34 @@ final public static function shouldBeArray(array $propertyPath): self /** * @param list $propertyPath - * @param non-empty-string $expectedFormat + * @param class-string $enumName + * + * @return self + */ + final public static function invalidChoice(array $propertyPath, string $enumName): self + { + $choices = []; + foreach ($enumName::cases() as $case) { + $choices[] = $case->value; + } + + return new self( + sprintf('This value should be one of: %s.', join(', ', $choices)), + ErrorCode::INVALID_CHOICE, + $propertyPath, + ); + } + + /** + * @param list $propertyPath + * @param string $format * * @return self */ - final public static function invalidTimestamp(array $propertyPath, string $expectedFormat): self + final public static function invalidTimestamp(array $propertyPath, string $format): self { return new self( - sprintf('This value is not a valid timestamp, expected format: %s.', $expectedFormat), + sprintf('This value should be in the format "%s".', $format), ErrorCode::INVALID_TIMESTAMP, $propertyPath, ); @@ -182,36 +202,42 @@ final public static function invalidTimestamp(array $propertyPath, string $expec /** * @param list $propertyPath - * @param class-string $enumName * * @return self */ - final public static function invalidChoice(array $propertyPath, string $enumName): self + final public static function invalidTimezone(array $propertyPath): self { - /** @var list $validCases */ - $validCases = $enumName::cases(); - $expectedChoices = []; - foreach ($validCases as $validCase) { - $expectedChoices[] = $validCase->value; - } + return new self( + 'This value is not a valid timezone.', + ErrorCode::INVALID_TIMEZONE, + $propertyPath, + ); + } + /** + * @param list $propertyPath + * + * @return self + */ + final public static function invalidUid(array $propertyPath): self + { return new self( - sprintf('This value is not a valid choice, expected choices: %s.', join(', ', $expectedChoices)), - ErrorCode::INVALID_CHOICE, + 'This value is not a valid UID.', + ErrorCode::INVALID_UID, $propertyPath, ); } /** * @param list $propertyPath - * @param int<1, max> $limit + * @param int<0, max> $limit * * @return self */ final public static function redundantElement(array $propertyPath, int $limit): self { return new self( - sprintf('This element is redundant, limit: %d.', $limit), + sprintf('The maximum allowed number of elements is %d.', $limit), ErrorCode::REDUNDANT_ELEMENT, $propertyPath, ); diff --git a/src/Exception/UnsupportedPropertyTypeException.php b/src/Exception/UnsupportedPropertyTypeException.php index 3a8a81a..cd34fb8 100644 --- a/src/Exception/UnsupportedPropertyTypeException.php +++ b/src/Exception/UnsupportedPropertyTypeException.php @@ -13,9 +13,45 @@ namespace Sunrise\Hydrator\Exception; +use ReflectionProperty; + +use function sprintf; + /** * UnsupportedPropertyTypeException */ class UnsupportedPropertyTypeException extends InvalidObjectException { + + /** + * @param ReflectionProperty $property + * @param string $typeName + * + * @return self + */ + public static function unsupportedType(ReflectionProperty $property, string $typeName): self + { + return new self(sprintf( + 'The property %s.%s contains an unsupported type %s.', + $property->getDeclaringClass()->getName(), + $property->getName(), + $typeName, + )); + } + + /** + * @param ReflectionProperty $property + * @param string $className + * + * @return self + */ + public static function nonInstantiableClass(ReflectionProperty $property, string $className): self + { + return new self(sprintf( + 'The property %s.%s refers to a non-instantiable class %s.', + $property->getDeclaringClass()->getName(), + $property->getName(), + $className, + )); + } } diff --git a/src/Hydrator.php b/src/Hydrator.php index 7a9b8e0..c8c4829 100644 --- a/src/Hydrator.php +++ b/src/Hydrator.php @@ -13,44 +13,37 @@ namespace Sunrise\Hydrator; -use BackedEnum; -use Doctrine\Common\Annotations\AnnotationReader; -use Doctrine\Common\Annotations\Reader as AnnotationReaderInterface; use JsonException; -use Sunrise\Hydrator\Annotation\Alias; -use Sunrise\Hydrator\Annotation\Format; -use Sunrise\Hydrator\Annotation\Ignore; -use Sunrise\Hydrator\Annotation\Relationship; -use Sunrise\Hydrator\Exception\InvalidDataException; -use Sunrise\Hydrator\Exception\InvalidValueException; -use DateTimeImmutable; use LogicException; -use ReflectionAttribute; use ReflectionClass; -use ReflectionEnum; use ReflectionNamedType; use ReflectionProperty; -use ValueError; +use SimdJsonException; +use Sunrise\Hydrator\Annotation\Alias; +use Sunrise\Hydrator\Annotation\Ignore; +use Sunrise\Hydrator\Exception\InvalidDataException; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\TypeConverter\ArrayTypeConverter; +use Sunrise\Hydrator\TypeConverter\BackedEnumTypeConverter; +use Sunrise\Hydrator\TypeConverter\BoolTypeConverter; +use Sunrise\Hydrator\TypeConverter\FloatTypeConverter; +use Sunrise\Hydrator\TypeConverter\IntTypeConverter; +use Sunrise\Hydrator\TypeConverter\RelationshipTypeConverter; +use Sunrise\Hydrator\TypeConverter\StringTypeConverter; +use Sunrise\Hydrator\TypeConverter\TimestampTypeConverter; +use Sunrise\Hydrator\TypeConverter\TimezoneTypeConverter; +use Sunrise\Hydrator\TypeConverter\UidTypeConverter; use function array_key_exists; -use function class_exists; use function extension_loaded; -use function filter_var; use function is_array; -use function is_bool; -use function is_float; -use function is_int; use function is_object; -use function is_string; -use function is_subclass_of; use function json_decode; +use function simdjson_decode; use function sprintf; -use function trim; +use function usort; -use const FILTER_NULL_ON_FAILURE; -use const FILTER_VALIDATE_BOOLEAN; -use const FILTER_VALIDATE_FLOAT; -use const FILTER_VALIDATE_INT; +use function var_dump; use const JSON_THROW_ON_ERROR; use const PHP_MAJOR_VERSION; use const PHP_VERSION_ID; @@ -62,50 +55,106 @@ class Hydrator implements HydratorInterface { /** - * @var AnnotationReaderInterface|null + * @var AnnotationReaderInterface */ - private ?AnnotationReaderInterface $annotationReader = null; + private AnnotationReaderInterface $annotationReader; /** - * Gets the annotation reader - * - * @return AnnotationReaderInterface|null + * @var list + */ + private array $typeConverters = []; + + /** + * Constructor of the class + */ + public function __construct() + { + $this->annotationReader = PHP_MAJOR_VERSION >= 8 ? new AnnotationReader() : DoctrineAnnotationReader::default(); + + $this->addTypeConverter( + new BoolTypeConverter(), + new IntTypeConverter(), + new FloatTypeConverter(), + new StringTypeConverter(), + new BackedEnumTypeConverter(), + new TimestampTypeConverter(), + new TimezoneTypeConverter(), + new UidTypeConverter(), + new ArrayTypeConverter(), + new RelationshipTypeConverter(), + ); + } + + /** + * @inheritDoc + */ + public function addTypeConverter(TypeConverterInterface ...$typeConverters): void + { + foreach ($typeConverters as $typeConverter) { + if ($typeConverter instanceof AnnotationReaderAwareInterface) { + $typeConverter->setAnnotationReader($this->annotationReader); + } + if ($typeConverter instanceof HydratorAwareInterface) { + $typeConverter->setHydrator($this); + } + + $this->typeConverters[] = $typeConverter; + } + + // phpcs:ignore Generic.Files.LineLength + usort($this->typeConverters, static fn(TypeConverterInterface $a, TypeConverterInterface $b): int => $b->getWeight() <=> $a->getWeight()); + } + + /** + * @inheritDoc */ - public function getAnnotationReader(): ?AnnotationReaderInterface + public function castValue($value, Type $type, array $path) { - return $this->annotationReader; + foreach ($this->typeConverters as $typeConverter) { + $result = $typeConverter->castValue($value, $type, $path); + if ($result->valid()) { + return $result->current(); + } + } + + throw Exception\UnsupportedPropertyTypeException::unsupportedType($type->getHolder(), $type->getName()); } /** * Sets the given annotation reader * - * @param AnnotationReaderInterface|null $annotationReader + * @param AnnotationReaderInterface|\Doctrine\Common\Annotations\Reader $annotationReader * * @return self */ - public function setAnnotationReader(?AnnotationReaderInterface $annotationReader): self + public function setAnnotationReader($annotationReader): self { - $this->annotationReader = $annotationReader; + if ($annotationReader instanceof AnnotationReaderInterface) { + $this->annotationReader = $annotationReader; + return $this; + } - return $this; + // BC with previous versions... + if ($annotationReader instanceof \Doctrine\Common\Annotations\Reader) { + $this->annotationReader = new DoctrineAnnotationReader($annotationReader); + return $this; + } + + throw new LogicException('Unsupported annotation reader'); } /** - * Uses the default annotation reader + * Uses the doctrine's default annotation reader * * @return self * - * @throws LogicException - * If the doctrine/annotations package isn't installed. + * @throws LogicException If the doctrine/annotations package isn't installed on the server. + * + * @deprecated 3.1.0 */ public function useDefaultAnnotationReader(): self { - // @codeCoverageIgnoreStart - if (!class_exists(AnnotationReader::class)) { - throw new LogicException('The package doctrine/annotations is required.'); - } // @codeCoverageIgnoreEnd - - $this->annotationReader = new AnnotationReader(); + $this->annotationReader = DoctrineAnnotationReader::default(); return $this; } @@ -135,13 +184,13 @@ public function useDefaultAnnotationReader(): self */ public function hydrate($object, array $data, array $path = []): object { - $object = $this->instantObject($object); - $class = new ReflectionClass($object); + [$object, $class] = $this->instantObject($object); $properties = $class->getProperties(); $defaultValues = $this->getClassConstructorDefaultValues($class); $violations = []; foreach ($properties as $property) { if (PHP_VERSION_ID < 80100) { + /** @psalm-suppress UnusedMethodCall */ $property->setAccessible(true); } @@ -149,13 +198,12 @@ public function hydrate($object, array $data, array $path = []): object continue; } - $ignore = $this->getPropertyAnnotation($property, Ignore::class); - if (isset($ignore)) { + if ($this->annotationReader->getAnnotations($property, Ignore::class)->valid()) { continue; } $key = $property->getName(); - $alias = $this->getPropertyAnnotation($property, Alias::class); + $alias = $this->annotationReader->getAnnotations($property, Alias::class)->current(); if (isset($alias)) { $key = $alias->value; } @@ -171,7 +219,6 @@ public function hydrate($object, array $data, array $path = []): object } $violations[] = InvalidValueException::shouldBeProvided([...$path, $key]); - continue; } @@ -198,6 +245,7 @@ public function hydrate($object, array $data, array $path = []): object * @param string $json * @param int<0, max> $flags * @param int<1, 2147483647> $depth + * @param list $path * * @return T * @@ -215,21 +263,23 @@ public function hydrate($object, array $data, array $path = []): object * * @template T of object */ - public function hydrateWithJson($object, string $json, int $flags = 0, int $depth = 512): object + public function hydrateWithJson($object, string $json, int $flags = 0, int $depth = 512, array $path = []): object { // @codeCoverageIgnoreStart - if (!extension_loaded('json')) { - throw new LogicException('JSON extension is required.'); + if (!extension_loaded('json') && !extension_loaded('simdjson')) { + throw new LogicException('Requires JSON or Simdjson extension.'); } // @codeCoverageIgnoreEnd try { - $data = json_decode($json, true, $depth, $flags | JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new InvalidDataException(sprintf('Invalid JSON: %s', $e->getMessage())); + // phpcs:ignore Generic.Files.LineLength + $data = extension_loaded('simdjson') ? simdjson_decode($json, true, $depth) : json_decode($json, true, $depth, $flags | JSON_THROW_ON_ERROR); + } catch (JsonException|SimdJsonException $e) { + // phpcs:ignore Generic.Files.LineLength + throw new InvalidDataException(sprintf('The JSON is invalid and couldn‘t be decoded due to: %s', $e->getMessage())); } if (!is_array($data)) { - throw new InvalidDataException('JSON must be an object.'); + throw new InvalidDataException('The JSON must be in the form of an array or an object.'); } return $this->hydrate($object, $data); @@ -240,20 +290,21 @@ public function hydrateWithJson($object, string $json, int $flags = 0, int $dept * * @param class-string|T $object * - * @return T + * @return array{0: T, 1: ReflectionClass} * * @throws Exception\UninitializableObjectException * If the given object cannot be instantiated. * * @template T of object */ - private function instantObject($object): object + private function instantObject($object): array { + $class = new ReflectionClass($object); + if (is_object($object)) { - return $object; + return [$object, $class]; } - $class = new ReflectionClass($object); if (!$class->isInstantiable()) { throw new Exception\UninitializableObjectException(sprintf( 'The class %s cannot be hydrated because it is an uninstantiable class.', @@ -261,8 +312,7 @@ private function instantObject($object): object )); } - /** @var T */ - return $class->newInstanceWithoutConstructor(); + return [$class->newInstanceWithoutConstructor(), $class]; } /** @@ -275,533 +325,42 @@ private function instantObject($object): object * * @return void * - * @throws InvalidValueException - * If the given value is invalid. + * @throws InvalidDataException If the given value is invalid. * - * @throws InvalidDataException - * If the given value is invalid. + * @throws InvalidValueException If the given value is invalid. * - * @throws Exception\UntypedPropertyException - * If the given property isn't typed. + * @throws Exception\UntypedPropertyException If the given property isn't typed. * - * @throws Exception\UnsupportedPropertyTypeException - * If the given property contains an unsupported type. + * @throws Exception\UnsupportedPropertyTypeException If the given property contains an unsupported type. */ - private function hydrateProperty( - object $object, - ReflectionProperty $property, - $value, - array $path - ): void { + private function hydrateProperty(object $object, ReflectionProperty $property, $value, array $path): void + { $type = $this->getPropertyType($property); - $typeName = $type->getName(); if ($value === null) { - $this->hydratePropertyWithNull($object, $property, $type, $path); - return; - } - if ($typeName === 'bool') { - $this->hydrateBooleanProperty($object, $property, $type, $value, $path); - return; - } - if ($typeName === 'int') { - $this->hydrateIntegerProperty($object, $property, $type, $value, $path); - return; - } - if ($typeName === 'float') { - $this->hydrateNumericProperty($object, $property, $type, $value, $path); - return; - } - if ($typeName === 'string') { - $this->hydrateStringProperty($object, $property, $value, $path); - return; - } - if ($typeName === 'array') { - $this->hydrateArrayProperty($object, $property, $value, $path); - return; - } - if ($typeName === DateTimeImmutable::class) { - $this->hydrateTimestampProperty($object, $property, $type, $value, $path); - return; - } - if (is_subclass_of($typeName, BackedEnum::class)) { - $this->hydrateEnumerableProperty($object, $property, $type, $typeName, $value, $path); - return; - } - if (class_exists($typeName)) { - $this->hydrateRelationshipProperty($object, $property, $typeName, $value, $path); - return; - } - - throw new Exception\UnsupportedPropertyTypeException(sprintf( - 'The property %s.%s contains an unsupported type %s.', - $property->getDeclaringClass()->getName(), - $property->getName(), - $typeName, - )); - } - - /** - * Hydrates the given property with null - * - * @param object $object - * @param ReflectionProperty $property - * @param ReflectionNamedType $type - * @param list $path - * - * @return void - * - * @throws InvalidValueException - * If the given value isn't valid. - */ - private function hydratePropertyWithNull( - object $object, - ReflectionProperty $property, - ReflectionNamedType $type, - array $path - ): void { - if (!$type->allowsNull()) { - throw InvalidValueException::shouldNotBeEmpty($path); - } - - $property->setValue($object, null); - } - - /** - * Hydrates the given boolean property with the given value - * - * @param object $object - * @param ReflectionProperty $property - * @param ReflectionNamedType $type - * @param mixed $value - * @param list $path - * - * @return void - * - * @throws InvalidValueException - * If the given value isn't valid. - */ - private function hydrateBooleanProperty( - object $object, - ReflectionProperty $property, - ReflectionNamedType $type, - $value, - array $path - ): void { - if (is_string($value)) { - // As part of the support for HTML forms and other untyped data sources, - // an empty string should not be cast to a boolean type, therefore, - // such values should be treated as NULL. - if (trim($value) === '') { - $this->hydratePropertyWithNull($object, $property, $type, $path); - return; - } - - // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L273 - $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - } - - if (!is_bool($value)) { - throw InvalidValueException::shouldBeBoolean($path); - } - - $property->setValue($object, $value); - } - - /** - * Hydrates the given integer property with the given value - * - * @param object $object - * @param ReflectionProperty $property - * @param ReflectionNamedType $type - * @param mixed $value - * @param list $path - * - * @return void - * - * @throws InvalidValueException - * If the given value isn't valid. - */ - private function hydrateIntegerProperty( - object $object, - ReflectionProperty $property, - ReflectionNamedType $type, - $value, - array $path - ): void { - if (is_string($value)) { - // As part of the support for HTML forms and other untyped data sources, - // an empty string cannot be cast to an integer type, therefore, - // such values should be treated as NULL. - if (trim($value) === '') { - $this->hydratePropertyWithNull($object, $property, $type, $path); - return; + if (!$type->allowsNull()) { + throw InvalidValueException::shouldNotBeEmpty($path); } - // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L94 - // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L197 - $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); - } - - if (!is_int($value)) { - throw InvalidValueException::shouldBeInteger($path); - } - - $property->setValue($object, $value); - } - - /** - * Hydrates the given numeric property with the given value - * - * @param object $object - * @param ReflectionProperty $property - * @param ReflectionNamedType $type - * @param mixed $value - * @param list $path - * - * @return void - * - * @throws InvalidValueException - * If the given value isn't valid. - */ - private function hydrateNumericProperty( - object $object, - ReflectionProperty $property, - ReflectionNamedType $type, - $value, - array $path - ): void { - if (is_string($value)) { - // As part of the support for HTML forms and other untyped data sources, - // an empty string cannot be cast to a number type, therefore, - // such values should be treated as NULL. - if (trim($value) === '') { - $this->hydratePropertyWithNull($object, $property, $type, $path); - return; - } - - // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L342 - $value = filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); - } - - if (is_int($value)) { - $value = (float) $value; - } - - if (!is_float($value)) { - throw InvalidValueException::shouldBeNumber($path); - } - - $property->setValue($object, $value); - } - - /** - * Hydrates the given string property with the given value - * - * @param object $object - * @param ReflectionProperty $property - * @param mixed $value - * @param list $path - * - * @return void - * - * @throws InvalidValueException - * If the given value isn't valid. - */ - private function hydrateStringProperty( - object $object, - ReflectionProperty $property, - $value, - array $path - ): void { - if (!is_string($value)) { - throw InvalidValueException::shouldBeString($path); - } - - $property->setValue($object, $value); - } - - /** - * Hydrates the given array property with the given value - * - * @param object $object - * @param ReflectionProperty $property - * @param mixed $value - * @param list $path - * - * @return void - * - * @throws InvalidValueException - * If the given value isn't valid. - */ - private function hydrateArrayProperty( - object $object, - ReflectionProperty $property, - $value, - array $path - ): void { - $relationship = $this->getPropertyAnnotation($property, Relationship::class); - if (isset($relationship)) { - $this->hydrateRelationshipsProperty($object, $property, $relationship, $value, $path); + $property->setValue($object, null); return; } - if (!is_array($value)) { - throw InvalidValueException::shouldBeArray($path); - } - - $property->setValue($object, $value); - } - - /** - * Hydrates the given timestamp property with the given value - * - * @param object $object - * @param ReflectionProperty $property - * @param ReflectionNamedType $type - * @param mixed $value - * @param list $path - * - * @return void - * - * @throws InvalidValueException - * If the given value isn't valid. - * - * @throws Exception\UnsupportedPropertyTypeException - * If the given property doesn't contain the Format attribute. - */ - private function hydrateTimestampProperty( - object $object, - ReflectionProperty $property, - ReflectionNamedType $type, - $value, - array $path - ): void { - $format = $this->getPropertyAnnotation($property, Format::class); - if (!isset($format)) { - throw new Exception\UnsupportedPropertyTypeException(sprintf( - 'The property %1$s.%2$s must contain the attribute %3$s, ' . - 'for example: #[\%3$s(\DateTimeInterface::DATE_RFC3339)].', - $property->getDeclaringClass()->getName(), - $property->getName(), - Format::class, - )); - } - - if (is_string($value)) { - // As part of the support for HTML forms and other untyped data sources, - // an instance of DateTimeImmutable should not be created from an empty string, therefore, - // such values should be treated as NULL. - if (trim($value) === '') { - $this->hydratePropertyWithNull($object, $property, $type, $path); - return; - } - - if ($format->value === 'U') { - // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L94 - // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L197 - $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); - } - } - - if ($format->value === 'U' && !is_int($value)) { - throw InvalidValueException::shouldBeInteger($path); - } - if ($format->value !== 'U' && !is_string($value)) { - throw InvalidValueException::shouldBeString($path); - } - - /** @var int|string $value */ - - $timestamp = DateTimeImmutable::createFromFormat($format->value, (string) $value); - if ($timestamp === false) { - throw InvalidValueException::invalidTimestamp($path, $format->value); - } - - $property->setValue($object, $timestamp); - } - - /** - * Hydrates the given enumerable property with the given value - * - * @param object $object - * @param ReflectionProperty $property - * @param ReflectionNamedType $type - * @param class-string $enumName - * @param mixed $value - * @param list $path - * - * @return void - * - * @throws InvalidValueException - * If the given value isn't valid. - */ - private function hydrateEnumerableProperty( - object $object, - ReflectionProperty $property, - ReflectionNamedType $type, - string $enumName, - $value, - array $path - ): void { - $enumType = (string) (new ReflectionEnum($enumName))->getBackingType(); - - if (is_string($value)) { - // As part of the support for HTML forms and other untyped data sources, - // an instance of BackedEnum should not be created from an empty string, therefore, - // such values should be treated as NULL. - if (trim($value) === '') { - $this->hydratePropertyWithNull($object, $property, $type, $path); - return; - } - - if ($enumType === 'int') { - // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L94 - // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L197 - $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); - } - } - - if ($enumType === 'int' && !is_int($value)) { - throw InvalidValueException::shouldBeInteger($path); - } - if ($enumType === 'string' && !is_string($value)) { - throw InvalidValueException::shouldBeString($path); - } - - /** @var int|string $value */ - - try { - $property->setValue($object, $enumName::from($value)); - } catch (ValueError $e) { - throw InvalidValueException::invalidChoice($path, $enumName); - } - } - - /** - * Hydrates the given relationship property with the given value - * - * @param object $object - * @param ReflectionProperty $property - * @param class-string $className - * @param mixed $value - * @param list $path - * - * @return void - * - * @throws InvalidValueException - * If the given value isn't valid. - * - * @throws Exception\UnsupportedPropertyTypeException - * If the given property refers to a non-instantiable class. - */ - private function hydrateRelationshipProperty( - object $object, - ReflectionProperty $property, - string $className, - $value, - array $path - ): void { - $classReflection = new ReflectionClass($className); - if (!$classReflection->isInstantiable()) { - throw new Exception\UnsupportedPropertyTypeException(sprintf( - 'The property %s.%s refers to a non-instantiable class %s.', - $property->getDeclaringClass()->getName(), - $property->getName(), - $classReflection->getName(), - )); - } - - if (!is_array($value)) { - throw InvalidValueException::shouldBeArray($path); - } - - $classInstance = $classReflection->newInstanceWithoutConstructor(); - - $property->setValue($object, $this->hydrate($classInstance, $value, $path)); + $property->setValue($object, $this->castValue($value, $type, $path)); } /** - * Hydrates the given relationships property with the given value + * Gets the given property's type * - * @param object $object * @param ReflectionProperty $property - * @param Relationship $relationship - * @param mixed $value - * @param list $path * - * @return void + * @return Type * - * @throws InvalidDataException - * If the given value isn't valid. + * @throws Exception\UntypedPropertyException If the given property isn't typed. * - * @throws Exception\UnsupportedPropertyTypeException - * If the given property refers to a non-instantiable class. + * @throws Exception\UnsupportedPropertyTypeException If the given property contains an unsupported type. */ - private function hydrateRelationshipsProperty( - object $object, - ReflectionProperty $property, - Relationship $relationship, - $value, - array $path - ): void { - $classReflection = new ReflectionClass($relationship->target); - if (!$classReflection->isInstantiable()) { - throw new Exception\UnsupportedPropertyTypeException(sprintf( - 'The property %s.%s refers to a non-instantiable class %s.', - $property->getDeclaringClass()->getName(), - $property->getName(), - $classReflection->getName(), - )); - } - - if (!is_array($value)) { - throw InvalidValueException::shouldBeArray($path); - } - - $counter = 0; - $violations = []; - $classInstances = []; - $classPrototype = $classReflection->newInstanceWithoutConstructor(); - foreach ($value as $key => $data) { - if (isset($relationship->limit) && ++$counter > $relationship->limit) { - $violations[] = InvalidValueException::redundantElement([...$path, $key], $relationship->limit); - break; - } - - if (!is_array($data)) { - $violations[] = InvalidValueException::shouldBeArray([...$path, $key]); - continue; - } - - try { - $classInstances[$key] = $this->hydrate(clone $classPrototype, $data, [...$path, $key]); - } catch (InvalidDataException $e) { - $violations = [...$violations, ...$e->getExceptions()]; - } - } - - if (!empty($violations)) { - throw new InvalidDataException('Invalid data.', $violations); - } - - $property->setValue($object, $classInstances); - } - - /** - * Gets a type from the given property - * - * @param ReflectionProperty $property - * - * @return ReflectionNamedType - * - * @throws Exception\UntypedPropertyException - * If the given property isn't typed. - * - * @throws Exception\UnsupportedPropertyTypeException - * If the given property contains an unsupported type. - */ - private function getPropertyType(ReflectionProperty $property): ReflectionNamedType + private function getPropertyType(ReflectionProperty $property): Type { $type = $property->getType(); @@ -814,53 +373,10 @@ private function getPropertyType(ReflectionProperty $property): ReflectionNamedT } if (!($type instanceof ReflectionNamedType)) { - throw new Exception\UnsupportedPropertyTypeException(sprintf( - 'The property %s.%s contains an unsupported type %s.', - $property->getDeclaringClass()->getName(), - $property->getName(), - (string) $type, - )); + throw Exception\UnsupportedPropertyTypeException::unsupportedType($property, (string) $type); } - return $type; - } - - /** - * Gets an annotation from the given property - * - * @param ReflectionProperty $property - * @param class-string $annotationName - * - * @return T|null - * - * @template T of object - */ - private function getPropertyAnnotation(ReflectionProperty $property, string $annotationName): ?object - { - if (PHP_MAJOR_VERSION >= 8) { - /** - * @psalm-var list $annotations - * @phpstan-var list> $annotations - * @psalm-suppress TooManyTemplateParams - */ - $annotations = $property->getAttributes($annotationName); - - if (isset($annotations[0])) { - /** @var T */ - return $annotations[0]->newInstance(); - } - } - - if (isset($this->annotationReader)) { - $annotations = $this->annotationReader->getPropertyAnnotations($property); - foreach ($annotations as $annotation) { - if ($annotation instanceof $annotationName) { - return $annotation; - } - } - } - - return null; + return new Type($property, $type->getName(), $type->allowsNull()); } /** @@ -874,14 +390,16 @@ private function getPropertyAnnotation(ReflectionProperty $property, string $ann */ private function getClassConstructorDefaultValues(ReflectionClass $class): array { - $result = []; $constructor = $class->getConstructor(); - if (isset($constructor)) { - foreach ($constructor->getParameters() as $parameter) { - if ($parameter->isDefaultValueAvailable()) { - /** @psalm-suppress MixedAssignment */ - $result[$parameter->getName()] = $parameter->getDefaultValue(); - } + if ($constructor === null) { + return []; + } + + $result = []; + foreach ($constructor->getParameters() as $parameter) { + if ($parameter->isDefaultValueAvailable()) { + /** @psalm-suppress MixedAssignment */ + $result[$parameter->getName()] = $parameter->getDefaultValue(); } } diff --git a/src/HydratorAwareInterface.php b/src/HydratorAwareInterface.php new file mode 100644 index 0000000..419837e --- /dev/null +++ b/src/HydratorAwareInterface.php @@ -0,0 +1,30 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator; + +/** + * @since 3.1.0 + */ +interface HydratorAwareInterface +{ + + /** + * Sets the given hydrator + * + * @param HydratorInterface $hydrator + * + * @return void + */ + public function setHydrator(HydratorInterface $hydrator): void; +} diff --git a/src/HydratorInterface.php b/src/HydratorInterface.php index f3a5edc..e304b95 100644 --- a/src/HydratorInterface.php +++ b/src/HydratorInterface.php @@ -15,6 +15,8 @@ use Sunrise\Hydrator\Exception\InvalidDataException; use Sunrise\Hydrator\Exception\InvalidObjectException; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\Exception\UnsupportedPropertyTypeException; /** * HydratorInterface @@ -22,23 +24,52 @@ interface HydratorInterface { + /** + * Adds the given type converter(s) to the hydrator + * + * @param TypeConverterInterface ...$typeConverters + * + * @return void + * + * @since 3.1.0 + */ + public function addTypeConverter(TypeConverterInterface ...$typeConverters): void; + + /** + * Tries to cast the given value to the given type + * + * @param mixed $value + * @param Type $type + * @param list $path + * + * @return mixed + * + * @throws InvalidDataException If one of the value elements isn't valid. + * + * @throws InvalidValueException If the value isn't valid. + * + * @throws UnsupportedPropertyTypeException If the type isn't supported. + * + * @since 3.1.0 + */ + public function castValue($value, Type $type, array $path); + /** * Hydrates the given object with the given data * * @param class-string|T $object * @param array $data + * @param list $path * * @return T * - * @throws InvalidDataException - * If the given data is invalid. + * @throws InvalidDataException If the given data is invalid. * - * @throws InvalidObjectException - * If the given object is invalid. + * @throws InvalidObjectException If the given object is invalid. * * @template T of object */ - public function hydrate($object, array $data): object; + public function hydrate($object, array $data, array $path = []): object; /** * Hydrates the given object with the given JSON @@ -47,16 +78,15 @@ public function hydrate($object, array $data): object; * @param string $json * @param int<0, max> $flags * @param int<1, 2147483647> $depth + * @param list $path * * @return T * - * @throws InvalidDataException - * If the given data is invalid. + * @throws InvalidDataException If the given data is invalid. * - * @throws InvalidObjectException - * If the given object is invalid. + * @throws InvalidObjectException If the given object is invalid. * * @template T of object */ - public function hydrateWithJson($object, string $json, int $flags = 0, int $depth = 512): object; + public function hydrateWithJson($object, string $json, int $flags = 0, int $depth = 512, array $path = []): object; } diff --git a/src/Type.php b/src/Type.php new file mode 100644 index 0000000..efbe239 --- /dev/null +++ b/src/Type.php @@ -0,0 +1,88 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator; + +use ReflectionProperty; + +/** + * @since 3.1.0 + */ +final class Type +{ + + /** + * The type holder + * + * @var ReflectionProperty + */ + private ReflectionProperty $holder; + + /** + * The type name + * + * @var string + */ + private string $name; + + /** + * Indicates whether the type allows null + * + * @var bool + */ + private bool $allowsNull; + + /** + * Constructor of the class + * + * @param ReflectionProperty $holder + * @param string $name + * @param bool $allowsNull + */ + public function __construct(ReflectionProperty $holder, string $name, bool $allowsNull) + { + $this->holder = $holder; + $this->name = $name; + $this->allowsNull = $allowsNull; + } + + /** + * Gets the type holder + * + * @return ReflectionProperty + */ + public function getHolder(): ReflectionProperty + { + return $this->holder; + } + + /** + * Gets the type name + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Checks if the type allows null + * + * @return bool + */ + public function allowsNull(): bool + { + return $this->allowsNull; + } +} diff --git a/src/TypeConverter/ArrayTypeConverter.php b/src/TypeConverter/ArrayTypeConverter.php new file mode 100644 index 0000000..98ca762 --- /dev/null +++ b/src/TypeConverter/ArrayTypeConverter.php @@ -0,0 +1,146 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\TypeConverter; + +use ArrayAccess; +use Generator; +use OverflowException; +use ReflectionClass; +use Sunrise\Hydrator\Annotation\Subtype; +use Sunrise\Hydrator\AnnotationReaderAwareInterface; +use Sunrise\Hydrator\AnnotationReaderInterface; +use Sunrise\Hydrator\Dictionary\BuiltinType; +use Sunrise\Hydrator\Exception\InvalidDataException; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\Exception\UnsupportedPropertyTypeException; +use Sunrise\Hydrator\HydratorAwareInterface; +use Sunrise\Hydrator\HydratorInterface; +use Sunrise\Hydrator\Type; +use Sunrise\Hydrator\TypeConverterInterface; + +use function is_array; +use function is_subclass_of; + +/** + * @since 3.1.0 + * + * @psalm-suppress MissingConstructor + */ +final class ArrayTypeConverter implements TypeConverterInterface, AnnotationReaderAwareInterface, HydratorAwareInterface +{ + + /** + * @var AnnotationReaderInterface + */ + private AnnotationReaderInterface $annotationReader; + + /** + * @var HydratorInterface + */ + private HydratorInterface $hydrator; + + /** + * @inheritDoc + */ + public function setAnnotationReader(AnnotationReaderInterface $annotationReader): void + { + $this->annotationReader = $annotationReader; + } + + /** + * @inheritDoc + */ + public function setHydrator(HydratorInterface $hydrator): void + { + $this->hydrator = $hydrator; + } + + /** + * @inheritDoc + * + * @psalm-suppress MixedAssignment + */ + public function castValue($value, Type $type, array $path): Generator + { + $containerName = $type->getName(); + if ($containerName <> BuiltinType::ARRAY && !is_subclass_of($containerName, ArrayAccess::class)) { + return; + } + + if (!is_array($value)) { + throw InvalidValueException::shouldBeArray($path); + } + + $container = []; + if ($containerName <> BuiltinType::ARRAY) { + $containerReflection = new ReflectionClass($containerName); + if (!$containerReflection->isInstantiable()) { + throw UnsupportedPropertyTypeException::nonInstantiableClass($type->getHolder(), $containerName); + } + + $container = $containerReflection->newInstanceWithoutConstructor(); + } + + $valueSubtype = $this->annotationReader->getAnnotations($type->getHolder(), Subtype::class)->current(); + if ($valueSubtype === null) { + $elementCounter = 0; + foreach ($value as $key => $element) { + try { + $container[$key] = $element; + ++$elementCounter; + } catch (OverflowException $e) { + throw InvalidValueException::redundantElement([...$path, $key], $elementCounter); + } + } + + return yield $container; + } + + $elementCounter = 0; + $elementType = new Type($type->getHolder(), $valueSubtype->name, false); + $violations = []; + foreach ($value as $key => $element) { + if (isset($valueSubtype->limit) && $elementCounter >= $valueSubtype->limit) { + $violations[] = InvalidValueException::redundantElement([...$path, $key], $valueSubtype->limit); + break; + } + + try { + $container[$key] = $this->hydrator->castValue($element, $elementType, [...$path, $key]); + ++$elementCounter; + } catch (InvalidDataException $e) { + $violations = [...$violations, ...$e->getExceptions()]; + } catch (InvalidValueException $e) { + $violations[] = $e; + } catch (OverflowException $e) { + $violations[] = InvalidValueException::redundantElement([...$path, $key], $elementCounter); + break; + } + } + + if (!empty($violations)) { + throw new InvalidDataException('Invalid data.', $violations); + } + + yield $container; + } + + /** + * @inheritDoc + */ + public function getWeight(): int + { + return 20; + } +} diff --git a/src/TypeConverter/BackedEnumTypeConverter.php b/src/TypeConverter/BackedEnumTypeConverter.php new file mode 100644 index 0000000..542c0eb --- /dev/null +++ b/src/TypeConverter/BackedEnumTypeConverter.php @@ -0,0 +1,105 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\TypeConverter; + +use BackedEnum; +use Generator; +use ReflectionEnum; +use ReflectionNamedType; +use Sunrise\Hydrator\Dictionary\BuiltinType; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\Type; +use Sunrise\Hydrator\TypeConverterInterface; +use ValueError; + +use function filter_var; +use function is_int; +use function is_string; +use function is_subclass_of; +use function trim; + +use const FILTER_NULL_ON_FAILURE; +use const FILTER_VALIDATE_INT; +use const PHP_VERSION_ID; + +/** + * @since 3.1.0 + */ +final class BackedEnumTypeConverter implements TypeConverterInterface +{ + + /** + * @inheritDoc + */ + public function castValue($value, Type $type, array $path): Generator + { + if (PHP_VERSION_ID < 80100) { + return; + } + + $enumName = $type->getName(); + if (!is_subclass_of($enumName, BackedEnum::class)) { + return; + } + + /** @var ReflectionNamedType $enumType */ + $enumType = (new ReflectionEnum($enumName))->getBackingType(); + + $enumTypeName = $enumType->getName(); + + if (is_string($value)) { + $value = trim($value); + + // As part of the support for HTML forms and other untyped data sources, + // empty strings should not be used to instantiate enumerations; + // instead, they should be considered as NULL. + if ($value === '') { + if ($type->allowsNull()) { + return yield null; + } + + throw InvalidValueException::shouldNotBeEmpty($path); + } + + if ($enumTypeName === BuiltinType::INT) { + // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L94 + // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L197 + $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + } + } + + if ($enumTypeName === BuiltinType::INT && !is_int($value)) { + throw InvalidValueException::shouldBeInteger($path); + } + if ($enumTypeName === BuiltinType::STRING && !is_string($value)) { + throw InvalidValueException::shouldBeString($path); + } + + /** @var int|string $value */ + + try { + yield $enumName::from($value); + } catch (ValueError $e) { + throw InvalidValueException::invalidChoice($path, $enumName); + } + } + + /** + * @inheritDoc + */ + public function getWeight(): int + { + return 60; + } +} diff --git a/src/TypeConverter/BoolTypeConverter.php b/src/TypeConverter/BoolTypeConverter.php new file mode 100644 index 0000000..34c472d --- /dev/null +++ b/src/TypeConverter/BoolTypeConverter.php @@ -0,0 +1,75 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\TypeConverter; + +use Generator; +use Sunrise\Hydrator\Dictionary\BuiltinType; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\Type; +use Sunrise\Hydrator\TypeConverterInterface; + +use function filter_var; +use function is_bool; +use function is_string; +use function trim; + +use const FILTER_NULL_ON_FAILURE; +use const FILTER_VALIDATE_BOOL; + +/** + * @since 3.1.0 + */ +final class BoolTypeConverter implements TypeConverterInterface +{ + + /** + * @inheritDoc + */ + public function castValue($value, Type $type, array $path): Generator + { + if ($type->getName() <> BuiltinType::BOOL) { + return; + } + + if (is_string($value)) { + // As part of the support for HTML forms and other untyped data sources, + // empty strings should not be cast to boolean types; + // instead, they should be considered as NULL. + if (trim($value) === '') { + if ($type->allowsNull()) { + return yield null; + } + + throw InvalidValueException::shouldNotBeEmpty($path); + } + + // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L273 + $value = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + } + + if (!is_bool($value)) { + throw InvalidValueException::shouldBeBoolean($path); + } + + yield $value; + } + + /** + * @inheritDoc + */ + public function getWeight(): int + { + return 100; + } +} diff --git a/src/TypeConverter/FloatTypeConverter.php b/src/TypeConverter/FloatTypeConverter.php new file mode 100644 index 0000000..f808f96 --- /dev/null +++ b/src/TypeConverter/FloatTypeConverter.php @@ -0,0 +1,80 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\TypeConverter; + +use Generator; +use Sunrise\Hydrator\Dictionary\BuiltinType; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\Type; +use Sunrise\Hydrator\TypeConverterInterface; + +use function filter_var; +use function is_float; +use function is_int; +use function is_string; +use function trim; + +use const FILTER_NULL_ON_FAILURE; +use const FILTER_VALIDATE_FLOAT; + +/** + * @since 3.1.0 + */ +final class FloatTypeConverter implements TypeConverterInterface +{ + + /** + * @inheritDoc + */ + public function castValue($value, Type $type, array $path): Generator + { + if ($type->getName() <> BuiltinType::FLOAT) { + return; + } + + if (is_int($value)) { + return yield $value + .0; + } + + if (is_string($value)) { + // As part of the support for HTML forms and other untyped data sources, + // empty strings should not be cast to number types; + // instead, they should be considered as NULL. + if (trim($value) === '') { + if ($type->allowsNull()) { + return yield null; + } + + throw InvalidValueException::shouldNotBeEmpty($path); + } + + // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L342 + $value = filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + } + + if (!is_float($value)) { + throw InvalidValueException::shouldBeNumber($path); + } + + yield $value; + } + + /** + * @inheritDoc + */ + public function getWeight(): int + { + return 80; + } +} diff --git a/src/TypeConverter/IntTypeConverter.php b/src/TypeConverter/IntTypeConverter.php new file mode 100644 index 0000000..8d58577 --- /dev/null +++ b/src/TypeConverter/IntTypeConverter.php @@ -0,0 +1,76 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\TypeConverter; + +use Generator; +use Sunrise\Hydrator\Dictionary\BuiltinType; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\Type; +use Sunrise\Hydrator\TypeConverterInterface; + +use function filter_var; +use function is_int; +use function is_string; +use function trim; + +use const FILTER_NULL_ON_FAILURE; +use const FILTER_VALIDATE_INT; + +/** + * @since 3.1.0 + */ +final class IntTypeConverter implements TypeConverterInterface +{ + + /** + * @inheritDoc + */ + public function castValue($value, Type $type, array $path): Generator + { + if ($type->getName() <> BuiltinType::INT) { + return; + } + + if (is_string($value)) { + // As part of the support for HTML forms and other untyped data sources, + // empty strings should not be cast to integer types; + // instead, they should be considered as NULL. + if (trim($value) === '') { + if ($type->allowsNull()) { + return yield null; + } + + throw InvalidValueException::shouldNotBeEmpty($path); + } + + // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L94 + // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L197 + $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + } + + if (!is_int($value)) { + throw InvalidValueException::shouldBeInteger($path); + } + + yield $value; + } + + /** + * @inheritDoc + */ + public function getWeight(): int + { + return 90; + } +} diff --git a/src/TypeConverter/RelationshipTypeConverter.php b/src/TypeConverter/RelationshipTypeConverter.php new file mode 100644 index 0000000..69aa525 --- /dev/null +++ b/src/TypeConverter/RelationshipTypeConverter.php @@ -0,0 +1,84 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\TypeConverter; + +use Generator; +use ReflectionClass; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\Exception\UnsupportedPropertyTypeException; +use Sunrise\Hydrator\HydratorAwareInterface; +use Sunrise\Hydrator\HydratorInterface; +use Sunrise\Hydrator\Type; +use Sunrise\Hydrator\TypeConverterInterface; + +use function class_exists; +use function is_array; + +/** + * @since 3.1.0 + * + * @psalm-suppress MissingConstructor + */ +final class RelationshipTypeConverter implements TypeConverterInterface, HydratorAwareInterface +{ + + /** + * @var HydratorInterface + */ + private HydratorInterface $hydrator; + + /** + * @inheritDoc + */ + public function setHydrator(HydratorInterface $hydrator): void + { + $this->hydrator = $hydrator; + } + + /** + * @inheritDoc + */ + public function castValue($value, Type $type, array $path): Generator + { + $className = $type->getName(); + if (!class_exists($className)) { + return; + } + + $classReflection = new ReflectionClass($className); + if ($classReflection->isInternal()) { + return; + } + + if (!$classReflection->isInstantiable()) { + throw UnsupportedPropertyTypeException::nonInstantiableClass($type->getHolder(), $className); + } + + if (!is_array($value)) { + throw InvalidValueException::shouldBeArray($path); + } + + $classInstance = $classReflection->newInstanceWithoutConstructor(); + + yield $this->hydrator->hydrate($classInstance, $value, $path); + } + + /** + * @inheritDoc + */ + public function getWeight(): int + { + return -100; + } +} diff --git a/src/TypeConverter/StringTypeConverter.php b/src/TypeConverter/StringTypeConverter.php new file mode 100644 index 0000000..b1a3fb9 --- /dev/null +++ b/src/TypeConverter/StringTypeConverter.php @@ -0,0 +1,53 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\TypeConverter; + +use Generator; +use Sunrise\Hydrator\Dictionary\BuiltinType; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\Type; +use Sunrise\Hydrator\TypeConverterInterface; + +use function is_string; + +/** + * @since 3.1.0 + */ +final class StringTypeConverter implements TypeConverterInterface +{ + + /** + * @inheritDoc + */ + public function castValue($value, Type $type, array $path): Generator + { + if ($type->getName() <> BuiltinType::STRING) { + return; + } + + if (!is_string($value)) { + throw InvalidValueException::shouldBeString($path); + } + + yield $value; + } + + /** + * @inheritDoc + */ + public function getWeight(): int + { + return 70; + } +} diff --git a/src/TypeConverter/TimestampTypeConverter.php b/src/TypeConverter/TimestampTypeConverter.php new file mode 100644 index 0000000..052f3be --- /dev/null +++ b/src/TypeConverter/TimestampTypeConverter.php @@ -0,0 +1,123 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\TypeConverter; + +use DateTimeImmutable; +use Generator; +use Sunrise\Hydrator\Annotation\Format; +use Sunrise\Hydrator\AnnotationReaderAwareInterface; +use Sunrise\Hydrator\AnnotationReaderInterface; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\Exception\UnsupportedPropertyTypeException; +use Sunrise\Hydrator\Type; +use Sunrise\Hydrator\TypeConverterInterface; + +use function filter_var; +use function is_a; +use function is_int; +use function is_string; +use function sprintf; +use function trim; + +use const FILTER_NULL_ON_FAILURE; +use const FILTER_VALIDATE_INT; + +/** + * @since 3.1.0 + * + * @psalm-suppress MissingConstructor + */ +final class TimestampTypeConverter implements TypeConverterInterface, AnnotationReaderAwareInterface +{ + + /** + * @var AnnotationReaderInterface + */ + private AnnotationReaderInterface $annotationReader; + + /** + * @inheritDoc + */ + public function setAnnotationReader(AnnotationReaderInterface $annotationReader): void + { + $this->annotationReader = $annotationReader; + } + + /** + * @inheritDoc + */ + public function castValue($value, Type $type, array $path): Generator + { + $className = $type->getName(); + if (!is_a($className, DateTimeImmutable::class, true)) { + return; + } + + $format = $this->annotationReader->getAnnotations($type->getHolder(), Format::class)->current(); + if ($format === null) { + throw new UnsupportedPropertyTypeException(sprintf( + 'The property %1$s.%2$s must contain the attribute %3$s, ' . + 'for example: #[\%3$s(\DateTimeInterface::DATE_RFC3339)].', + $type->getHolder()->getDeclaringClass()->getName(), + $type->getHolder()->getName(), + Format::class, + )); + } + + if (is_string($value)) { + $value = trim($value); + + // As part of the support for HTML forms and other untyped data sources, + // empty strings should not be used to instantiate timestamps; + // instead, they should be considered as NULL. + if ($value === '') { + if ($type->allowsNull()) { + return yield null; + } + + throw InvalidValueException::shouldNotBeEmpty($path); + } + + if ($format->value === 'U') { + // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L94 + // https://github.com/php/php-src/blob/b7d90f09d4a1688f2692f2fa9067d0a07f78cc7d/ext/filter/logical_filters.c#L197 + $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + } + } + + if ($format->value === 'U' && !is_int($value)) { + throw InvalidValueException::shouldBeInteger($path); + } + if ($format->value !== 'U' && !is_string($value)) { + throw InvalidValueException::shouldBeString($path); + } + + /** @var int|string $value */ + + $timestamp = $className::createFromFormat($format->value, (string) $value); + if ($timestamp === false) { + throw InvalidValueException::invalidTimestamp($path, $format->value); + } + + yield $timestamp; + } + + /** + * @inheritDoc + */ + public function getWeight(): int + { + return 50; + } +} diff --git a/src/TypeConverter/TimezoneTypeConverter.php b/src/TypeConverter/TimezoneTypeConverter.php new file mode 100644 index 0000000..d75b33c --- /dev/null +++ b/src/TypeConverter/TimezoneTypeConverter.php @@ -0,0 +1,75 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\TypeConverter; + +use DateTimeZone; +use Exception; +use Generator; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\Type; +use Sunrise\Hydrator\TypeConverterInterface; + +use function is_a; +use function is_string; +use function trim; + +/** + * @since 3.1.0 + */ +final class TimezoneTypeConverter implements TypeConverterInterface +{ + + /** + * @inheritDoc + */ + public function castValue($value, Type $type, array $path): Generator + { + $className = $type->getName(); + if (!is_a($className, DateTimeZone::class, true)) { + return; + } + + if (!is_string($value)) { + throw InvalidValueException::shouldBeString($path); + } + + $value = trim($value); + + // As part of the support for HTML forms and other untyped data sources, + // empty strings should not be used to instantiate timezones; + // instead, they should be considered as NULL. + if ($value === '') { + if ($type->allowsNull()) { + return yield null; + } + + throw InvalidValueException::shouldNotBeEmpty($path); + } + + try { + /** @psalm-suppress UnsafeInstantiation */ + yield new $className($value); + } catch (Exception $e) { + throw InvalidValueException::invalidTimezone($path); + } + } + + /** + * @inheritDoc + */ + public function getWeight(): int + { + return 40; + } +} diff --git a/src/TypeConverter/UidTypeConverter.php b/src/TypeConverter/UidTypeConverter.php new file mode 100644 index 0000000..274f24d --- /dev/null +++ b/src/TypeConverter/UidTypeConverter.php @@ -0,0 +1,76 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator\TypeConverter; + +use Generator; +use InvalidArgumentException; +use Sunrise\Hydrator\Exception\InvalidValueException; +use Sunrise\Hydrator\Type; +use Sunrise\Hydrator\TypeConverterInterface; +use Symfony\Component\Uid\AbstractUid; + +use function is_string; +use function is_subclass_of; +use function trim; + +/** + * @link https://github.com/symfony/uid + * + * @since 3.1.0 + */ +final class UidTypeConverter implements TypeConverterInterface +{ + + /** + * @inheritDoc + */ + public function castValue($value, Type $type, array $path): Generator + { + $className = $type->getName(); + if (!is_subclass_of($className, AbstractUid::class)) { + return; + } + + if (!is_string($value)) { + throw InvalidValueException::shouldBeString($path); + } + + $value = trim($value); + + // As part of the support for HTML forms and other untyped data sources, + // empty strings should not be used to instantiate uids; + // instead, they should be considered as NULL. + if ($value === '') { + if ($type->allowsNull()) { + return yield null; + } + + throw InvalidValueException::shouldNotBeEmpty($path); + } + + try { + yield $className::fromString($value); + } catch (InvalidArgumentException $e) { + throw InvalidValueException::invalidUid($path); + } + } + + /** + * @inheritDoc + */ + public function getWeight(): int + { + return 30; + } +} diff --git a/src/TypeConverterInterface.php b/src/TypeConverterInterface.php new file mode 100644 index 0000000..799604f --- /dev/null +++ b/src/TypeConverterInterface.php @@ -0,0 +1,47 @@ + + * @copyright Copyright (c) 2021, Anatoly Nekhay + * @license https://github.com/sunrise-php/hydrator/blob/master/LICENSE + * @link https://github.com/sunrise-php/hydrator + */ + +declare(strict_types=1); + +namespace Sunrise\Hydrator; + +use Generator; +use Sunrise\Hydrator\Exception\InvalidDataException; +use Sunrise\Hydrator\Exception\InvalidValueException; + +/** + * @since 3.1.0 + */ +interface TypeConverterInterface +{ + + /** + * Tries to cast the given value to the given type + * + * @param mixed $value + * @param Type $type + * @param list $path + * + * @return Generator + * + * @throws InvalidDataException If one of the value items isn't valid. + * + * @throws InvalidValueException If the value isn't valid. + */ + public function castValue($value, Type $type, array $path): Generator; + + /** + * Gets the converter weight + * + * @return int + */ + public function getWeight(): int; +} diff --git a/tests/Fixtures/Collection.php b/tests/Fixtures/Collection.php new file mode 100644 index 0000000..9eacb68 --- /dev/null +++ b/tests/Fixtures/Collection.php @@ -0,0 +1,37 @@ +elements[$offset]); + } + + public function offsetGet($offset) + { + return $this->elements[$offset] ?? null; + } + + public function offsetSet($offset, $value) + { + if (!empty($this->elements)) { + throw new OverflowException(); + } + + $this->elements[$offset] = $value; + } + + public function offsetUnset($offset) + { + unset($this->elements[$offset]); + } +} diff --git a/tests/Fixtures/ObjectWithCollection.php b/tests/Fixtures/ObjectWithCollection.php new file mode 100644 index 0000000..d54ee33 --- /dev/null +++ b/tests/Fixtures/ObjectWithCollection.php @@ -0,0 +1,10 @@ +createHydrator()->hydrate(ObjectWithArray::class, []); } + /** + * @group collection + * @dataProvider arrayDataProvider + */ + public function testHydrateCollectionProperty(array $data, array $expected): void + { + $this->assertInvalidValueExceptionCount(0); + $object = $this->createHydrator()->hydrate(ObjectWithCollection::class, $data); + $this->assertSame($expected, $object->value->elements); + } + + /** + * @group collection + * @dataProvider arrayDataProvider + * @dataProvider strictNullDataProvider + */ + public function testHydrateNullableCollectionProperty(array $data, ?array $expected): void + { + $this->assertInvalidValueExceptionCount(0); + $object = $this->createHydrator()->hydrate(ObjectWithNullableCollection::class, $data); + $this->assertSame($expected, isset($object->value) ? $object->value->elements : null); + } + + /** + * @group collection + * @dataProvider arrayDataProvider + * @dataProvider emptyDataProvider + */ + public function testHydrateOptionalCollectionProperty(array $data, array $expected = []): void + { + $this->assertInvalidValueExceptionCount(0); + $object = $this->createHydrator()->hydrate(ObjectWithOptionalCollection::class, $data); + $this->assertSame($expected, isset($object->value) ? $object->value->elements : []); + } + + /** + * @group collection + * @dataProvider strictNullDataProvider + */ + public function testHydrateNonNullableCollectionPropertyWithNull(array $data): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'This value should not be empty.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::VALUE_SHOULD_NOT_BE_EMPTY); + $this->assertInvalidValueExceptionPropertyPath(0, 'value'); + $this->createHydrator()->hydrate(ObjectWithCollection::class, $data); + } + + /** + * @group collection + * @dataProvider notArrayDataProvider + */ + public function testHydrateCollectionPropertyWithNotArray(array $data): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'This value should be of type array.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::VALUE_SHOULD_BE_ARRAY); + $this->assertInvalidValueExceptionPropertyPath(0, 'value'); + $this->createHydrator()->hydrate(ObjectWithCollection::class, $data); + } + + /** + * @group collection + */ + public function testHydrateRequiredCollectionPropertyWithoutValue(): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'This value should be provided.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::VALUE_SHOULD_BE_PROVIDED); + $this->assertInvalidValueExceptionPropertyPath(0, 'value'); + $this->createHydrator()->hydrate(ObjectWithCollection::class, []); + } + + /** + * @group collection + */ + public function testOverflowCollection(): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'The maximum allowed number of elements is 1.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::REDUNDANT_ELEMENT); + $this->assertInvalidValueExceptionPropertyPath(0, 'value.1'); + $this->createHydrator()->hydrate(ObjectWithCollection::class, ['value' => [ + ['value' => 'foo'], + ['value' => 'bar'], + ]]); + } + /** * @group datetimeTimestamp * @dataProvider timestampDataProvider @@ -533,7 +629,7 @@ public function testHydrateTimestampPropertyWithNotString(array $data): void public function testHydrateTimestampPropertyWithInvalidTimestamp(array $data): void { $this->assertInvalidValueExceptionCount(1); - $message = sprintf('This value is not a valid timestamp, expected format: %s.', ObjectWithTimestamp::FORMAT); + $message = sprintf('This value should be in the format "%s".', ObjectWithTimestamp::FORMAT); $this->assertInvalidValueExceptionMessage(0, $message); $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::INVALID_TIMESTAMP); $this->assertInvalidValueExceptionPropertyPath(0, 'value'); @@ -650,6 +746,182 @@ public function testHydrateRequiredUnixTimeStampPropertyWithoutValue(): void $this->createHydrator()->hydrate(ObjectWithUnixTimeStamp::class, []); } + /** + * @group timezone + * @dataProvider timezoneDataProvider + */ + public function testHydrateTimezoneProperty(array $data, string $expected): void + { + $this->assertInvalidValueExceptionCount(0); + $object = $this->createHydrator()->hydrate(ObjectWithTimezone::class, $data); + $this->assertSame($expected, $object->value->getName()); + } + + /** + * @group timezone + * @dataProvider timezoneDataProvider + * @dataProvider strictNullDataProvider + * @dataProvider nonStrictNullDataProvider + */ + public function testHydrateNullableTimezoneProperty(array $data, ?string $expected = null): void + { + $this->assertInvalidValueExceptionCount(0); + $object = $this->createHydrator()->hydrate(ObjectWithNullableTimezone::class, $data); + $this->assertSame($expected, isset($object->value) ? $object->value->getName() : null); + } + + /** + * @group timezone + * @dataProvider timezoneDataProvider + * @dataProvider emptyDataProvider + */ + public function testHydrateOptionalTimezoneProperty(array $data, ?string $expected = null): void + { + $this->assertInvalidValueExceptionCount(0); + $object = $this->createHydrator()->hydrate(ObjectWithOptionalTimezone::class, $data); + $this->assertSame($expected, isset($object->value) ? $object->value->getName() : null); + } + + /** + * @group timezone + * @dataProvider strictNullDataProvider + * @dataProvider nonStrictNullDataProvider + */ + public function testHydrateNonNullableTimezonePropertyWithNull(array $data): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'This value should not be empty.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::VALUE_SHOULD_NOT_BE_EMPTY); + $this->assertInvalidValueExceptionPropertyPath(0, 'value'); + $this->createHydrator()->hydrate(ObjectWithTimezone::class, $data); + } + + /** + * @group timezone + * @dataProvider notStringDataProvider + */ + public function testHydrateTimezonePropertyWithNotString(array $data): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'This value should be of type string.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::VALUE_SHOULD_BE_STRING); + $this->assertInvalidValueExceptionPropertyPath(0, 'value'); + $this->createHydrator()->hydrate(ObjectWithTimezone::class, $data); + } + + /** + * @group timezone + * @dataProvider invalidTimezoneDataProvider + */ + public function testHydrateTimezonePropertyWithInvalidTimezone(array $data): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'This value is not a valid timezone.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::INVALID_TIMEZONE); + $this->assertInvalidValueExceptionPropertyPath(0, 'value'); + $this->createHydrator()->hydrate(ObjectWithTimezone::class, $data); + } + + /** + * @group timezone + */ + public function testHydrateRequiredTimezonePropertyWithoutValue(): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'This value should be provided.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::VALUE_SHOULD_BE_PROVIDED); + $this->assertInvalidValueExceptionPropertyPath(0, 'value'); + $this->createHydrator()->hydrate(ObjectWithTimezone::class, []); + } + + /** + * @group uid + * @dataProvider uidDataProvider + */ + public function testHydrateUidProperty(array $data, string $expected): void + { + $this->assertInvalidValueExceptionCount(0); + $object = $this->createHydrator()->hydrate(ObjectWithUid::class, $data); + $this->assertSame($expected, $object->value->toRfc4122()); + } + + /** + * @group uid + * @dataProvider uidDataProvider + * @dataProvider strictNullDataProvider + * @dataProvider nonStrictNullDataProvider + */ + public function testHydrateNullableUidProperty(array $data, ?string $expected = null): void + { + $this->assertInvalidValueExceptionCount(0); + $object = $this->createHydrator()->hydrate(ObjectWithNullableUid::class, $data); + $this->assertSame($expected, isset($object->value) ? $object->value->toRfc4122() : null); + } + + /** + * @group uid + * @dataProvider uidDataProvider + * @dataProvider emptyDataProvider + */ + public function testHydrateOptionalUidProperty(array $data, ?string $expected = null): void + { + $this->assertInvalidValueExceptionCount(0); + $object = $this->createHydrator()->hydrate(ObjectWithOptionalUid::class, $data); + $this->assertSame($expected, isset($object->value) ? $object->value->toRfc4122() : null); + } + + /** + * @group uid + * @dataProvider strictNullDataProvider + * @dataProvider nonStrictNullDataProvider + */ + public function testHydrateNonNullableUidPropertyWithNull(array $data): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'This value should not be empty.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::VALUE_SHOULD_NOT_BE_EMPTY); + $this->assertInvalidValueExceptionPropertyPath(0, 'value'); + $this->createHydrator()->hydrate(ObjectWithUid::class, $data); + } + + /** + * @group uid + * @dataProvider notStringDataProvider + */ + public function testHydrateUidPropertyWithNotString(array $data): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'This value should be of type string.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::VALUE_SHOULD_BE_STRING); + $this->assertInvalidValueExceptionPropertyPath(0, 'value'); + $this->createHydrator()->hydrate(ObjectWithUid::class, $data); + } + + /** + * @group uid + * @dataProvider invalidUidDataProvider + */ + public function testHydrateUidPropertyWithInvalidUid(array $data): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'This value is not a valid UID.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::INVALID_UID); + $this->assertInvalidValueExceptionPropertyPath(0, 'value'); + $this->createHydrator()->hydrate(ObjectWithUid::class, $data); + } + + /** + * @group uid + */ + public function testHydrateRequiredUidPropertyWithoutValue(): void + { + $this->assertInvalidValueExceptionCount(1); + $this->assertInvalidValueExceptionMessage(0, 'This value should be provided.'); + $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::VALUE_SHOULD_BE_PROVIDED); + $this->assertInvalidValueExceptionPropertyPath(0, 'value'); + $this->createHydrator()->hydrate(ObjectWithUid::class, []); + } + /** * @group integerEnumeration * @dataProvider strictIntegerEnumerationDataProvider @@ -745,7 +1017,7 @@ public function testHydrateIntegerEnumerationPropertyWithInvalidChoice(): void $this->phpRequired('8.1'); $this->assertInvalidValueExceptionCount(1); // phpcs:ignore Generic.Files.LineLength - $this->assertInvalidValueExceptionMessage(0, 'This value is not a valid choice, expected choices: 1, 2, 3.'); + $this->assertInvalidValueExceptionMessage(0, 'This value should be one of: 1, 2, 3.'); $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::INVALID_CHOICE); $this->assertInvalidValueExceptionPropertyPath(0, 'value'); $this->createHydrator()->hydrate(ObjectWithIntegerEnum::class, ['value' => 42]); @@ -831,7 +1103,7 @@ public function testHydrateStringEnumerationPropertyWithInvalidChoice(): void $this->phpRequired('8.1'); $this->assertInvalidValueExceptionCount(1); // phpcs:ignore Generic.Files.LineLength - $this->assertInvalidValueExceptionMessage(0, 'This value is not a valid choice, expected choices: foo, bar, baz.'); + $this->assertInvalidValueExceptionMessage(0, 'This value should be one of: foo, bar, baz.'); $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::INVALID_CHOICE); $this->assertInvalidValueExceptionPropertyPath(0, 'value'); $this->createHydrator()->hydrate(ObjectWithStringEnum::class, ['value' => 'unknown']); @@ -1163,7 +1435,7 @@ public function testSeveralErrorsWhenHydratingRelationshipsProperty(): void public function testHydrateLimitedRelationshipsProperty(): void { $this->assertInvalidValueExceptionCount(1); - $this->assertInvalidValueExceptionMessage(0, 'This element is redundant, limit: 1.'); + $this->assertInvalidValueExceptionMessage(0, 'The maximum allowed number of elements is 1.'); $this->assertInvalidValueExceptionErrorCode(0, ErrorCode::REDUNDANT_ELEMENT); $this->assertInvalidValueExceptionPropertyPath(0, 'value.1'); $this->createHydrator()->hydrate(ObjectWithRelationshipsWithLimit::class, ['value' => [ @@ -1207,8 +1479,8 @@ public function testHydrateObjectWithJson(): void public function testHydrateObjectWithInvalidJson(): void { $this->expectException(InvalidDataException::class); - $this->expectExceptionMessage('Invalid JSON: Maximum stack depth exceeded'); - $this->createHydrator()->hydrateWithJson(ObjectWithString::class, '[]', 0, 1); + $this->expectExceptionMessageMatches('/^The JSON is invalid and couldn‘t be decoded due to: .+$/'); + $this->createHydrator()->hydrateWithJson(ObjectWithString::class, '[[]]', 0, 1); } /** @@ -1217,7 +1489,7 @@ public function testHydrateObjectWithInvalidJson(): void public function testHydrateObjectWithNonObjectableJson(): void { $this->expectException(InvalidDataException::class); - $this->expectExceptionMessage('JSON must be an object.'); + $this->expectExceptionMessage('The JSON must be in the form of an array or an object.'); $this->createHydrator()->hydrateWithJson(ObjectWithString::class, 'null'); } @@ -1332,22 +1604,6 @@ public function testAttributedAlias(): void $this->assertInvalidValueExceptionPropertyPath(0, 'value'); } - public function testAnnotationReader(): void - { - /** @var Hydrator $hydrator */ - $hydrator = $this->createHydrator(); - - $hydrator->setAnnotationReader(null); - $this->assertNull($hydrator->getAnnotationReader()); - - $reader = $this->createMock(Reader::class); - $hydrator->setAnnotationReader($reader); - $this->assertSame($reader, $hydrator->getAnnotationReader()); - - $hydrator->setAnnotationReader(null); - $this->assertNull($hydrator->getAnnotationReader()); - } - public function testHydrateStore(): void { $this->phpRequired('8.1'); @@ -1644,6 +1900,36 @@ public function notUnixTimeStampDataProvider(): array ]; } + public function timezoneDataProvider(): array + { + return [ + [['value' => 'Europe/Belgrade'], 'Europe/Belgrade'], + ]; + } + + public function invalidTimezoneDataProvider(): array + { + return [ + [['value' => 'Haafingar/Solitude']], + ]; + } + + public function uidDataProvider(): array + { + return [ + [['value' => '207ddb61-c300-4368-9f26-33d0a99eac00'], '207ddb61-c300-4368-9f26-33d0a99eac00'], + ]; + } + + public function invalidUidDataProvider(): array + { + return [ + [['value' => '207ddb61-c300-4368-9f26-33d0a99eac0']], + [['value' => '207ddb61-c300-4368-9f26-33d0a99eac0x']], + [['value' => '207ddb61-c300-4368-9f26-33d0a99eac000']], + ]; + } + public function strictIntegerEnumerationDataProvider(): array { if (PHP_VERSION_ID < 80100) { From cf6be1ff409f803ddc8a8c76c83939c9c146d1ae Mon Sep 17 00:00:00 2001 From: Anatoly Nekhay Date: Fri, 29 Sep 2023 10:04:35 +0200 Subject: [PATCH 2/2] v3.1.0 --- README.md | 24 ++++++++++++++++++++++++ psalm.xml.dist | 1 + src/Hydrator.php | 1 - 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index aedb4fc..aaaec18 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ composer require sunrise/hydrator * * [String](#string) * * [Array](#array) * * [Timestamp](#timestamp) +* * [Timezone](#timezone) +* * [UID](#uid) * * [Enumeration](#enumeration) * * [Relationship](#relationship) * [Ignored property](#ignored-property) @@ -262,6 +264,28 @@ public readonly DateTimeImmutable $value; Also, please note that if a value in a dataset for this property is represented as an empty string or a string consisting only of whitespace, then the value will be handled as [null](#null). +### Timezone + +Only the DateTimeZone type is supported. + +```php +public readonly DateTimeZone $value; +``` + +Also, please note that if a value in a dataset for this property is represented as an empty string or a string consisting only of whitespace, then the value will be handled as [null](#null). + +### UID + +```bash +composer require symfony/uid +``` + +```php +public readonly \Symfony\Component\Uid\UuidV4 $value; +``` + +Also, please note that if a value in a dataset for this property is represented as an empty string or a string consisting only of whitespace, then the value will be handled as [null](#null). + ### Enumeration ```php diff --git a/psalm.xml.dist b/psalm.xml.dist index 38cce4c..7594a18 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -4,6 +4,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" + phpVersion="8.2" > diff --git a/src/Hydrator.php b/src/Hydrator.php index c8c4829..d58e6dd 100644 --- a/src/Hydrator.php +++ b/src/Hydrator.php @@ -43,7 +43,6 @@ use function sprintf; use function usort; -use function var_dump; use const JSON_THROW_ON_ERROR; use const PHP_MAJOR_VERSION; use const PHP_VERSION_ID;