From 45d3ad231c62556627e85fed17c982e43d4ef786 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Thu, 14 Nov 2024 13:01:20 +0100 Subject: [PATCH] [Serializer][PropertyInfo][Validator] TypeInfo 7.2 compatibility --- .../Tests/Extractor/PhpDocExtractorTest.php | 28 +++++++- .../Tests/Extractor/PhpStanExtractorTest.php | 10 ++- .../Extractor/ReflectionExtractorTest.php | 10 ++- .../PropertyInfo/Util/PhpDocTypeHelper.php | 29 ++++---- .../Normalizer/AbstractObjectNormalizer.php | 72 ++++++++++++++++--- .../Normalizer/ArrayDenormalizer.php | 12 +++- .../Mapping/Loader/PropertyInfoLoader.php | 64 ++++++++++++++--- 7 files changed, 185 insertions(+), 40 deletions(-) diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php index 7d72f9c274618..9d6f9f4ee73a8 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php @@ -27,6 +27,7 @@ use Symfony\Component\PropertyInfo\Tests\Fixtures\TraitUsage\DummyUsingTrait; use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\NullableType; /** * @author Kévin Dunglas @@ -562,7 +563,14 @@ public static function typeProvider(): iterable yield ['f', Type::list(Type::object(\DateTimeImmutable::class)), null, null]; yield ['g', Type::nullable(Type::array()), 'Nullable array.', null]; yield ['h', Type::nullable(Type::string()), null, null]; - yield ['i', Type::union(Type::int(), Type::string(), Type::null()), null, null]; + + // BC layer for type-info < 7.2 + if (!class_exists(NullableType::class)) { + yield ['i', Type::union(Type::int(), Type::string(), Type::null()), null, null]; + } else { + yield ['i', Type::nullable(Type::union(Type::int(), Type::string())), null, null]; + } + yield ['j', Type::nullable(Type::object(\DateTimeImmutable::class)), null, null]; yield ['nullableCollectionOfNonNullableElements', Type::nullable(Type::list(Type::int())), null, null]; yield ['donotexist', null, null, null]; @@ -629,7 +637,14 @@ public static function typeWithNoPrefixesProvider() yield ['f', null]; yield ['g', Type::nullable(Type::array())]; yield ['h', Type::nullable(Type::string())]; - yield ['i', Type::union(Type::int(), Type::string(), Type::null())]; + + // BC layer for type-info < 7.2 + if (!class_exists(NullableType::class)) { + yield ['i', Type::union(Type::int(), Type::string(), Type::null())]; + } else { + yield ['i', Type::nullable(Type::union(Type::int(), Type::string()))]; + } + yield ['j', Type::nullable(Type::object(\DateTimeImmutable::class))]; yield ['nullableCollectionOfNonNullableElements', Type::nullable(Type::list(Type::int()))]; yield ['donotexist', null]; @@ -693,7 +708,14 @@ public static function typeWithCustomPrefixesProvider(): iterable yield ['f', Type::list(Type::object(\DateTimeImmutable::class))]; yield ['g', Type::nullable(Type::array())]; yield ['h', Type::nullable(Type::string())]; - yield ['i', Type::union(Type::int(), Type::string(), Type::null())]; + + // BC layer for type-info < 7.2 + if (!class_exists(NullableType::class)) { + yield ['i', Type::union(Type::int(), Type::string(), Type::null())]; + } else { + yield ['i', Type::nullable(Type::union(Type::int(), Type::string()))]; + } + yield ['j', Type::nullable(Type::object(\DateTimeImmutable::class))]; yield ['nullableCollectionOfNonNullableElements', Type::nullable(Type::list(Type::int()))]; yield ['nonNullableCollectionOfNullableElements', Type::list(Type::nullable(Type::int()))]; diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php index 109d54f0898cf..6248e4966dc15 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php @@ -36,6 +36,7 @@ use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Exception\LogicException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; require_once __DIR__.'/../Fixtures/Extractor/DummyNamespace.php'; @@ -869,7 +870,14 @@ public function testPseudoTypes(string $property, ?Type $type) public static function pseudoTypesProvider(): iterable { yield ['classString', Type::string()]; - yield ['classStringGeneric', Type::generic(Type::string(), Type::object(\stdClass::class))]; + + // BC layer for type-info < 7.2 + if (!interface_exists(WrappingTypeInterface::class)) { + yield ['classStringGeneric', Type::generic(Type::string(), Type::object(\stdClass::class))]; + } else { + yield ['classStringGeneric', Type::string()]; + } + yield ['htmlEscapedString', Type::string()]; yield ['lowercaseString', Type::string()]; yield ['nonEmptyLowercaseString', Type::string()]; diff --git a/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php b/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php index c90e9b9e0c0dd..f60611e095342 100644 --- a/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php +++ b/src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php @@ -33,6 +33,7 @@ use Symfony\Component\PropertyInfo\Tests\Fixtures\SnakeCaseDummy; use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\TypeResolver\PhpDocAwareReflectionTypeResolver; /** @@ -772,7 +773,14 @@ public static function php80TypesProvider(): iterable yield ['foo', Type::nullable(Type::array())]; yield ['bar', Type::nullable(Type::int())]; yield ['timeout', Type::union(Type::int(), Type::float())]; - yield ['optional', Type::union(Type::nullable(Type::int()), Type::nullable(Type::float()))]; + + // BC layer for type-info < 7.2 + if (!class_exists(NullableType::class)) { + yield ['optional', Type::union(Type::nullable(Type::int()), Type::nullable(Type::float()))]; + } else { + yield ['optional', Type::nullable(Type::union(Type::float(), Type::int()))]; + } + yield ['string', Type::union(Type::string(), Type::object(\Stringable::class))]; yield ['payload', Type::mixed()]; yield ['data', Type::mixed()]; diff --git a/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php b/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php index 65b53977df7cf..79e7388a5a392 100644 --- a/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php +++ b/src/Symfony/Component/PropertyInfo/Util/PhpDocTypeHelper.php @@ -128,7 +128,9 @@ public function getType(DocType $varType): ?Type $nullable = true; } - return $this->createType($varType, $nullable); + $type = $this->createType($varType); + + return $nullable ? Type::nullable($type) : $type; } $varTypes = []; @@ -156,8 +158,7 @@ public function getType(DocType $varType): ?Type $unionTypes = []; foreach ($varTypes as $varType) { - $t = $this->createType($varType, $nullable); - if (null !== $t) { + if (null !== $t = $this->createType($varType)) { $unionTypes[] = $t; } } @@ -238,7 +239,7 @@ private function createLegacyType(DocType $type, bool $nullable): ?LegacyType /** * Creates a {@see Type} from a PHPDoc type. */ - private function createType(DocType $docType, bool $nullable): ?Type + private function createType(DocType $docType): ?Type { $docTypeString = (string) $docType; @@ -262,9 +263,8 @@ private function createType(DocType $docType, bool $nullable): ?Type } $type = null !== $class ? Type::object($class) : Type::builtin($phpType); - $type = Type::collection($type, ...$variableTypes); - return $nullable ? Type::nullable($type) : $type; + return Type::collection($type, ...$variableTypes); } if (!$docTypeString) { @@ -277,9 +277,8 @@ private function createType(DocType $docType, bool $nullable): ?Type if (str_starts_with($docTypeString, 'list<') && $docType instanceof Array_) { $collectionValueType = $this->getType($docType->getValueType()); - $type = Type::list($collectionValueType); - return $nullable ? Type::nullable($type) : $type; + return Type::list($collectionValueType); } if (str_starts_with($docTypeString, 'array<') && $docType instanceof Array_) { @@ -288,16 +287,14 @@ private function createType(DocType $docType, bool $nullable): ?Type $collectionKeyType = $this->getType($docType->getKeyType()); $collectionValueType = $this->getType($docType->getValueType()); - $type = Type::array($collectionValueType, $collectionKeyType); - - return $nullable ? Type::nullable($type) : $type; + return Type::array($collectionValueType, $collectionKeyType); } if ($docType instanceof PseudoType) { if ($docType->underlyingType() instanceof Integer) { - return $nullable ? Type::nullable(Type::int()) : Type::int(); + return Type::int(); } elseif ($docType->underlyingType() instanceof String_) { - return $nullable ? Type::nullable(Type::string()) : Type::string(); + return Type::string(); } } @@ -314,12 +311,10 @@ private function createType(DocType $docType, bool $nullable): ?Type [$phpType, $class] = $this->getPhpTypeAndClass($docTypeString); if ('array' === $docTypeString) { - return $nullable ? Type::nullable(Type::array()) : Type::array(); + return Type::array(); } - $type = null !== $class ? Type::object($class) : Type::builtin($phpType); - - return $nullable ? Type::nullable($type) : $type; + return null !== $class ? Type::object($class) : Type::builtin($phpType); } private function normalizeType(string $docType): string diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index 63068420ba12c..f8a3a41b5b003 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -34,10 +34,13 @@ use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Exception\LogicException as TypeInfoLogicException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Symfony\Component\TypeInfo\TypeIdentifier; /** @@ -644,7 +647,14 @@ private function validateAndDenormalizeLegacy(array $types, string $currentClass private function validateAndDenormalize(Type $type, string $currentClass, string $attribute, mixed $data, ?string $format, array $context): mixed { $expectedTypes = []; - $isUnionType = $type->asNonNullable() instanceof UnionType; + + // BC layer for type-info < 7.2 + if (method_exists(Type::class, 'asNonNullable')) { + $isUnionType = $type->asNonNullable() instanceof UnionType; + } else { + $isUnionType = $type instanceof UnionType; + } + $e = null; $extraAttributesException = null; $missingConstructorArgumentsException = null; @@ -667,12 +677,23 @@ private function validateAndDenormalize(Type $type, string $currentClass, string $collectionValueType = $t->getCollectionValueType(); } - $t = $t->getBaseType(); + // BC layer for type-info < 7.2 + if (method_exists(Type::class, 'getBaseType')) { + $t = $t->getBaseType(); + } else { + while ($t instanceof WrappingTypeInterface) { + $t = $t->getWrappedType(); + } + } // Fix a collection that contains the only one element // This is special to xml format only - if ('xml' === $format && $collectionValueType && !$collectionValueType->isA(TypeIdentifier::MIXED) && (!\is_array($data) || !\is_int(key($data)))) { - $data = [$data]; + if ('xml' === $format && $collectionValueType && (!\is_array($data) || !\is_int(key($data)))) { + // BC layer for type-info < 7.2 + $isMixedType = method_exists(Type::class, 'isA') ? $collectionValueType->isA(TypeIdentifier::MIXED) : $collectionValueType->isIdentifiedBy(TypeIdentifier::MIXED); + if (!$isMixedType) { + $data = [$data]; + } } // This try-catch should cover all NotNormalizableValueException (and all return branches after the first @@ -695,7 +716,10 @@ private function validateAndDenormalize(Type $type, string $currentClass, string return ''; } - $isNullable = $isNullable ?: $type->isNullable(); + // BC layer for type-info < 7.2 + if (method_exists(Type::class, 'isNullable')) { + $isNullable = $isNullable ?: $type->isNullable(); + } } switch ($typeIdentifier) { @@ -732,7 +756,16 @@ private function validateAndDenormalize(Type $type, string $currentClass, string if ($collectionValueType) { try { - $collectionValueBaseType = $collectionValueType->getBaseType(); + $collectionValueBaseType = $collectionValueType; + + // BC layer for type-info < 7.2 + if (!interface_exists(WrappingTypeInterface::class)) { + $collectionValueBaseType = $collectionValueType->getBaseType(); + } else { + while ($collectionValueBaseType instanceof WrappingTypeInterface) { + $collectionValueBaseType = $collectionValueBaseType->getWrappedType(); + } + } } catch (TypeInfoLogicException) { $collectionValueBaseType = Type::mixed(); } @@ -742,15 +775,29 @@ private function validateAndDenormalize(Type $type, string $currentClass, string $class = $collectionValueBaseType->getClassName().'[]'; $context['key_type'] = $collectionKeyType; $context['value_type'] = $collectionValueType; - } elseif (TypeIdentifier::ARRAY === $collectionValueBaseType->getTypeIdentifier()) { + } elseif ( + // BC layer for type-info < 7.2 + !class_exists(NullableType::class) && TypeIdentifier::ARRAY === $collectionValueBaseType->getTypeIdentifier() + || $collectionValueBaseType instanceof BuiltinType && TypeIdentifier::ARRAY === $collectionValueBaseType->getTypeIdentifier() + ) { // get inner type for any nested array $innerType = $collectionValueType; + if ($innerType instanceof NullableType) { + $innerType = $innerType->getWrappedType(); + } // note that it will break for any other builtinType $dimensions = '[]'; while ($innerType instanceof CollectionType) { $dimensions .= '[]'; $innerType = $innerType->getCollectionValueType(); + if ($innerType instanceof NullableType) { + $innerType = $innerType->getWrappedType(); + } + } + + while ($innerType instanceof WrappingTypeInterface) { + $innerType = $innerType->getWrappedType(); } if ($innerType instanceof ObjectType) { @@ -862,8 +909,15 @@ private function validateAndDenormalize(Type $type, string $currentClass, string throw $missingConstructorArgumentsException; } - if (!$isUnionType && $e) { - throw $e; + // BC layer for type-info < 7.2 + if (!class_exists(NullableType::class)) { + if (!$isUnionType && $e) { + throw $e; + } + } else { + if ($e && !($type instanceof UnionType && !$type instanceof NullableType)) { + throw $e; + } } if ($context[self::DISABLE_TYPE_ENFORCEMENT] ?? $this->defaultContext[self::DISABLE_TYPE_ENFORCEMENT] ?? false) { diff --git a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php index 347030c244c16..964d74b61a8bd 100644 --- a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php @@ -16,7 +16,9 @@ use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Denormalizes arrays of objects. @@ -59,7 +61,15 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $typeIdentifiers = []; if (null !== $keyType = ($context['key_type'] ?? null)) { if ($keyType instanceof Type) { - $typeIdentifiers = array_map(fn (Type $t): string => $t->getBaseType()->getTypeIdentifier()->value, $keyType instanceof UnionType ? $keyType->getTypes() : [$keyType]); + // BC layer for type-info < 7.2 + if (method_exists(Type::class, 'getBaseType')) { + $typeIdentifiers = array_map(fn (Type $t): string => $t->getBaseType()->getTypeIdentifier()->value, $keyType instanceof UnionType ? $keyType->getTypes() : [$keyType]); + } else { + /** @var list|BuiltinType> */ + $keyTypes = $keyType instanceof UnionType ? $keyType->getTypes() : [$keyType]; + + $typeIdentifiers = array_map(fn (BuiltinType $t): string => $t->getTypeIdentifier()->value, $keyTypes); + } } else { $typeIdentifiers = array_map(fn (LegacyType $t): string => $t->getBuiltinType(), \is_array($keyType) ? $keyType : [$keyType]); } diff --git a/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php b/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php index f878974ecc811..c95ffce534a2a 100644 --- a/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php +++ b/src/Symfony/Component/Validator/Mapping/Loader/PropertyInfoLoader.php @@ -16,10 +16,14 @@ use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\PropertyInfo\Type as PropertyInfoType; use Symfony\Component\TypeInfo\Type as TypeInfoType; +use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraints\All; use Symfony\Component\Validator\Constraints\NotBlank; @@ -143,7 +147,23 @@ public function loadClassMetadata(ClassMetadata $metadata): bool } $type = $types; - $nullable = false; + + // BC layer for type-info < 7.2 + if (!class_exists(NullableType::class)) { + $nullable = false; + + if ($type instanceof UnionType && $type->isNullable()) { + $nullable = true; + $type = $type->asNonNullable(); + } + } else { + $nullable = $type->isNullable(); + + if ($type instanceof NullableType) { + $type = $type->getWrappedType(); + } + } + if ($type instanceof UnionType && $type->isNullable()) { $nullable = true; @@ -197,18 +217,46 @@ private function getTypeConstraintLegacy(string $builtinType, PropertyInfoType $ private function getTypeConstraint(TypeInfoType $type): ?Type { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return ($type->isA(TypeIdentifier::INT) || $type->isA(TypeIdentifier::FLOAT) || $type->isA(TypeIdentifier::STRING) || $type->isA(TypeIdentifier::BOOL)) ? new Type(['type' => 'scalar']) : null; + // BC layer for type-info < 7.2 + if (!interface_exists(CompositeTypeInterface::class)) { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return ($type->isA(TypeIdentifier::INT) || $type->isA(TypeIdentifier::FLOAT) || $type->isA(TypeIdentifier::STRING) || $type->isA(TypeIdentifier::BOOL)) ? new Type(['type' => 'scalar']) : null; + } + + $baseType = $type->getBaseType(); + + if ($baseType instanceof ObjectType) { + return new Type(['type' => $baseType->getClassName()]); + } + + if (TypeIdentifier::MIXED !== $baseType->getTypeIdentifier()) { + return new Type(['type' => $baseType->getTypeIdentifier()->value]); + } + + return null; + } + + if ($type instanceof CompositeTypeInterface) { + return $type->isIdentifiedBy( + TypeIdentifier::INT, + TypeIdentifier::FLOAT, + TypeIdentifier::STRING, + TypeIdentifier::BOOL, + TypeIdentifier::TRUE, + TypeIdentifier::FALSE, + ) ? new Type(['type' => 'scalar']) : null; } - $baseType = $type->getBaseType(); + while ($type instanceof WrappingTypeInterface) { + $type = $type->getWrappedType(); + } - if ($baseType instanceof ObjectType) { - return new Type(['type' => $baseType->getClassName()]); + if ($type instanceof ObjectType) { + return new Type(['type' => $type->getClassName()]); } - if (TypeIdentifier::MIXED !== $baseType->getTypeIdentifier()) { - return new Type(['type' => $baseType->getTypeIdentifier()->value]); + if ($type instanceof BuiltinType && TypeIdentifier::MIXED !== $type->getTypeIdentifier()) { + return new Type(['type' => $type->getTypeIdentifier()->value]); } return null;