From 81650fdb2aa4c9b21eca8772eaaaecefa7580cbf Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Wed, 26 Feb 2025 17:52:28 +0100 Subject: [PATCH] [LiveComponent] Use TypeInfo `Type` --- src/LiveComponent/composer.json | 3 +- .../LiveComponentExtension.php | 2 +- .../src/LiveComponentHydrator.php | 133 ++++++++++++------ .../Metadata/LiveComponentMetadataFactory.php | 47 ++----- .../src/Metadata/LivePropMetadata.php | 26 +--- .../src/Util/QueryStringPropsExtractor.php | 42 +++++- 6 files changed, 145 insertions(+), 108 deletions(-) diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json index 7359a8b6eef..11de933f5a8 100644 --- a/src/LiveComponent/composer.json +++ b/src/LiveComponent/composer.json @@ -29,7 +29,9 @@ "php": ">=8.1", "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/property-access": "^5.4.5|^6.0|^7.0", + "symfony/property-info": "^5.4|^6.0|^7.0", "symfony/stimulus-bundle": "^2.9", + "symfony/type-info": "^7.2", "symfony/ux-twig-component": "^2.8", "twig/twig": "^3.8.0" }, @@ -46,7 +48,6 @@ "symfony/framework-bundle": "^5.4|^6.0|^7.0", "symfony/options-resolver": "^5.4|^6.0|^7.0", "symfony/phpunit-bridge": "^6.1|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", "symfony/security-bundle": "^5.4|^6.0|^7.0", "symfony/serializer": "^5.4|^6.0|^7.0", "symfony/twig-bundle": "^5.4|^6.0|^7.0", diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index dc04bfb10fa..3321a72d1c2 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -188,7 +188,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { $container->register('ux.live_component.metadata_factory', LiveComponentMetadataFactory::class) ->setArguments([ new Reference('ux.twig_component.component_factory'), - new Reference('property_info'), + new Reference('type_info.resolver'), ]) ->addTag('kernel.reset', ['method' => 'reset']) ; diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php index 95f4bf91f9c..4aba7ed37f0 100644 --- a/src/LiveComponent/src/LiveComponentHydrator.php +++ b/src/LiveComponent/src/LiveComponentHydrator.php @@ -18,10 +18,15 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; +use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Exception\HydrationException; @@ -266,33 +271,29 @@ public function hydrateValue(mixed $value, LivePropMetadata $propMetadata, objec throw new \LogicException(\sprintf('The LiveProp "%s" on component "%s" has "useSerializerForHydration: true", but the given serializer does not implement DenormalizerInterface.', $propMetadata->getName(), $parentObject::class)); } - if ($propMetadata->collectionValueType()) { - $builtInType = $propMetadata->collectionValueType()->getBuiltinType(); - if (Type::BUILTIN_TYPE_OBJECT === $builtInType) { - $type = $propMetadata->collectionValueType()->getClassName().'[]'; - } else { - $type = $builtInType.'[]'; - } - } else { - $type = $propMetadata->getType(); + if (null === $type = $propMetadata->getType()) { + throw new \LogicException(\sprintf('The "%s::%s" object should be hydrated with the Serializer, but no type could be guessed.', $parentObject::class, $propMetadata->getName())); } - if (null === $type) { - throw new \LogicException(\sprintf('The "%s::%s" object should be hydrated with the Serializer, but no type could be guessed.', $parentObject::class, $propMetadata->getName())); + if ($type->isNullable()) { + $type = $type->getWrappedType(); } - return $this->serializer->denormalize($value, $type, 'json', $propMetadata->serializationContext()); - } + if ($isCollection = $type instanceof CollectionType) { + $type = $type->getCollectionValueType(); + } - if ($propMetadata->collectionValueType() && Type::BUILTIN_TYPE_OBJECT === $propMetadata->collectionValueType()->getBuiltinType()) { - $collectionClass = $propMetadata->collectionValueType()->getClassName(); - foreach ($value as $key => $objectItem) { - $value[$key] = $this->hydrateObjectValue($objectItem, $collectionClass, true, $propMetadata->getFormat(), $parentObject::class, \sprintf('%s.%s', $propMetadata->getName(), $key), $parentObject); + while ($type instanceof WrappingTypeInterface) { + $type = $type->getWrappedType(); } + + $typeString = $type.($isCollection ? '[]' : ''); + + return $this->serializer->denormalize($value, $typeString, 'json', $propMetadata->serializationContext()); } // no type? no hydration - if (!$propMetadata->getType()) { + if (null === $type = $propMetadata->getType()) { return $value; } @@ -300,16 +301,41 @@ public function hydrateValue(mixed $value, LivePropMetadata $propMetadata, objec return null; } - if (\is_string($value) && $propMetadata->isBuiltIn() && \in_array($propMetadata->getType(), ['int', 'float', 'bool'], true)) { - return self::coerceStringValue($value, $propMetadata->getType(), $propMetadata->allowsNull()); + if ($isNullable = $type->isNullable()) { + $type = $type->getWrappedType(); } - // for all other built-ins: int, boolean, array, return as is - if ($propMetadata->isBuiltIn()) { - return $value; + if ($type instanceof CollectionType) { + $collectionValueType = $type->getCollectionValueType(); + if ($collectionValueType instanceof CompositeTypeInterface) { + $collectionValueType = $collectionValueType->getTypes()[0]; + } + + while ($collectionValueType instanceof WrappingTypeInterface) { + $collectionValueType = $collectionValueType->getWrappedType(); + } + + if ($collectionValueType instanceof ObjectType) { + foreach ($value as $key => $objectItem) { + $value[$key] = $this->hydrateObjectValue($objectItem, $collectionValueType->getClassName(), true, $propMetadata->getFormat(), $parentObject::class, \sprintf('%s.%s', $propMetadata->getName(), $key), $parentObject); + } + } + } + + if (\is_string($value) && $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT, TypeIdentifier::BOOL)) { + return self::coerceStringValue($value, $type, $isNullable); + } + + while ($type instanceof WrappingTypeInterface) { + $type = $type->getWrappedType(); + } + + if ($type instanceof ObjectType) { + return $this->hydrateObjectValue($value, $type->getClassName(), $isNullable, $propMetadata->getFormat(), $parentObject::class, $propMetadata->getName(), $parentObject); } - return $this->hydrateObjectValue($value, $propMetadata->getType(), $propMetadata->allowsNull(), $propMetadata->getFormat(), $parentObject::class, $propMetadata->getName(), $parentObject); + // for all other built-ins: int, boolean, array, return as is + return $value; } public function addChecksumToData(array $data): array @@ -319,18 +345,18 @@ public function addChecksumToData(array $data): array return $data; } - private static function coerceStringValue(string $value, string $type, bool $allowsNull): int|float|bool|null + private static function coerceStringValue(string $value, Type $type, bool $isNullable): int|float|bool|null { $value = trim($value); - if ('' === $value && $allowsNull) { + if ('' === $value && $isNullable) { return null; } - return match ($type) { - 'int' => (int) $value, - 'float' => (float) $value, - 'bool' => self::coerceStringToBoolean($value), + return match (true) { + $type->isIdentifiedBy(TypeIdentifier::INT) => (int) $value, + $type->isIdentifiedBy(TypeIdentifier::FLOAT) => (float) $value, + $type->isIdentifiedBy(TypeIdentifier::BOOL) => self::coerceStringToBoolean($value), default => throw new \LogicException(\sprintf('Cannot coerce value "%s" to type "%s"', $value, $type)), }; } @@ -462,15 +488,35 @@ private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, ob return $value; } + if (!$type = $propMetadata->getType()) { + throw new \LogicException(\sprintf('The "%s" property on component "%s" is missing its property-type. Add the "%s" type so the object can be hydrated later.', $propMetadata->getName(), $parentObject::class, $value::class)); + } + if (\is_array($value)) { - if ($propMetadata->collectionValueType() && Type::BUILTIN_TYPE_OBJECT === $propMetadata->collectionValueType()->getBuiltinType()) { - $collectionClass = $propMetadata->collectionValueType()->getClassName(); - foreach ($value as $key => $objectItem) { - if (!$objectItem instanceof $collectionClass) { - throw new \LogicException(\sprintf('The LiveProp "%s" on component "%s" is an array. We determined the array is full of %s objects, but at least one key had a different value of %s', $propMetadata->getName(), $parentObject::class, $collectionClass, get_debug_type($objectItem))); - } + if ($type->isNullable()) { + $type = $type->getWrappedType(); + } - $value[$key] = $this->dehydrateObjectValue($objectItem, $collectionClass, $propMetadata->getFormat(), $parentObject); + if ($type instanceof CollectionType) { + $collectionValueType = $type->getCollectionValueType(); + if ($collectionValueType instanceof CompositeTypeInterface) { + $collectionValueType = $collectionValueType->getTypes()[0]; + } + + while ($collectionValueType instanceof WrappingTypeInterface) { + $collectionValueType = $collectionValueType->getWrappedType(); + } + + if ($collectionValueType instanceof ObjectType) { + $collectionClass = $collectionValueType->getClassName(); + + foreach ($value as $key => $objectItem) { + if (!$objectItem instanceof $collectionClass) { + throw new \LogicException(\sprintf('The LiveProp "%s" on component "%s" is an array. We determined the array is full of %s objects, but at least one key had a different value of %s', $propMetadata->getName(), $parentObject::class, $collectionClass, get_debug_type($objectItem))); + } + + $value[$key] = $this->dehydrateObjectValue($objectItem, $collectionClass, $propMetadata->getFormat(), $parentObject); + } } } @@ -485,14 +531,15 @@ private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, ob throw new \LogicException(\sprintf('Unable to dehydrate value of type "%s" for property "%s" on component "%s". Change this to a simpler type of an object that can be dehydrated. Or set the hydrateWith/dehydrateWith options in LiveProp or set "useSerializerForHydration: true" on the LiveProp to use the serializer.', get_debug_type($value), $propMetadata->getName(), $parentObject::class)); } - if (!$propMetadata->getType() || $propMetadata->isBuiltIn()) { - throw new \LogicException(\sprintf('The "%s" property on component "%s" is missing its property-type. Add the "%s" type so the object can be hydrated later.', $propMetadata->getName(), $parentObject::class, $value::class)); + while ($type instanceof WrappingTypeInterface) { + $type = $type->getWrappedType(); } - // at this point, we have an object and can assume $propMetadata->getType() - // is set correctly (needed for hydration later) + if ($type instanceof ObjectType) { + return $this->dehydrateObjectValue($value, $type->getClassName(), $propMetadata->getFormat(), $parentObject); + } - return $this->dehydrateObjectValue($value, $propMetadata->getType(), $propMetadata->getFormat(), $parentObject); + return $value; } private function dehydrateObjectValue(object $value, string $classType, ?string $dateFormat, object $parentObject): mixed diff --git a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php index 78fd36ab836..0ba115c2af0 100644 --- a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php +++ b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php @@ -11,8 +11,11 @@ namespace Symfony\UX\LiveComponent\Metadata; -use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\NullableType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; use Symfony\Contracts\Service\ResetInterface; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\TwigComponent\ComponentFactory; @@ -29,7 +32,7 @@ class LiveComponentMetadataFactory implements ResetInterface public function __construct( private ComponentFactory $componentFactory, - private PropertyTypeExtractorInterface $propertyTypeExtractor, + private TypeResolverInterface $typeResolver, ) { } @@ -74,41 +77,17 @@ public function createPropMetadatas(\ReflectionClass $class): array public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, LiveProp $liveProp): LivePropMetadata { - $type = $property->getType(); - if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { - throw new \LogicException(\sprintf('Union or intersection types are not supported for LiveProps. You may want to change the type of property %s in %s.', $property->getName(), $property->getDeclaringClass()->getName())); + try { + $type = $this->typeResolver->resolve($property); + } catch (UnsupportedException) { + $type = null; } - $infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? []; - - $collectionValueType = null; - foreach ($infoTypes as $infoType) { - if ($infoType->isCollection()) { - foreach ($infoType->getCollectionValueTypes() as $valueType) { - $collectionValueType = $valueType; - break; - } - } - } - - if (null === $type && null === $collectionValueType && isset($infoTypes[0])) { - $infoType = Type::BUILTIN_TYPE_OBJECT === $infoTypes[0]->getBuiltinType() ? $infoTypes[0]->getClassName() : $infoTypes[0]->getBuiltinType(); - $isTypeBuiltIn = null === $infoTypes[0]->getClassName(); - $isTypeNullable = $infoTypes[0]->isNullable(); - } else { - $infoType = $type?->getName(); - $isTypeBuiltIn = $type?->isBuiltin() ?? false; - $isTypeNullable = $type?->allowsNull() ?? true; + if ($type instanceof UnionType && !$type instanceof NullableType || $type instanceof IntersectionType) { + throw new \LogicException(\sprintf('Union or intersection types are not supported for LiveProps. You may want to change the type of property "%s" in "%s".', $propertyName, $className)); } - return new LivePropMetadata( - $property->getName(), - $liveProp, - $infoType, - $isTypeBuiltIn, - $isTypeNullable, - $collectionValueType - ); + return new LivePropMetadata($property->getName(), $liveProp, $type); } /** diff --git a/src/LiveComponent/src/Metadata/LivePropMetadata.php b/src/LiveComponent/src/Metadata/LivePropMetadata.php index b6a94b7f163..aa463e1b9b8 100644 --- a/src/LiveComponent/src/Metadata/LivePropMetadata.php +++ b/src/LiveComponent/src/Metadata/LivePropMetadata.php @@ -11,7 +11,7 @@ namespace Symfony\UX\LiveComponent\Metadata; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\TypeInfo\Type; use Symfony\UX\LiveComponent\Attribute\LiveProp; /** @@ -24,10 +24,7 @@ final class LivePropMetadata public function __construct( private string $name, private LiveProp $liveProp, - private ?string $typeName, - private bool $isBuiltIn, - private bool $allowsNull, - private ?Type $collectionValueType, + private ?Type $type, ) { } @@ -36,19 +33,9 @@ public function getName(): string return $this->name; } - public function getType(): ?string + public function getType(): ?Type { - return $this->typeName; - } - - public function isBuiltIn(): bool - { - return $this->isBuiltIn; - } - - public function allowsNull(): bool - { - return $this->allowsNull; + return $this->type; } public function urlMapping(): ?UrlMapping @@ -99,11 +86,6 @@ public function serializationContext(): array return $this->liveProp->serializationContext(); } - public function collectionValueType(): ?Type - { - return $this->collectionValueType; - } - public function getFormat(): ?string { return $this->liveProp->format(); diff --git a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php b/src/LiveComponent/src/Util/QueryStringPropsExtractor.php index 48e852d70ba..ff822233483 100644 --- a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php +++ b/src/LiveComponent/src/Util/QueryStringPropsExtractor.php @@ -12,6 +12,12 @@ namespace Symfony\UX\LiveComponent\Util; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; +use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\UX\LiveComponent\Exception\HydrationException; use Symfony\UX\LiveComponent\LiveComponentHydrator; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; @@ -40,11 +46,20 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec } $data = []; + $typeIsScalar = function (Type $type) use (&$typeIsScalar): bool { + return match (true) { + $type instanceof BuiltinType => $type->getTypeIdentifier()->isScalar(), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsScalar), + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsScalar), + default => false, + }; + }; + foreach ($metadata->getAllLivePropsMetadata($component) as $livePropMetadata) { if ($queryMapping = $livePropMetadata->urlMapping()) { $frontendName = $livePropMetadata->calculateFieldName($component, $livePropMetadata->getName()); if (null !== ($value = $query[$queryMapping->as ?? $frontendName] ?? null)) { - if ('' === $value && null !== $livePropMetadata->getType() && (!$livePropMetadata->isBuiltIn() || 'array' === $livePropMetadata->getType())) { + if ('' === $value && null !== $livePropMetadata->getType() && (!$livePropMetadata->getType()->isSatisfiedBy($typeIsScalar))) { // Cast empty string to empty array for objects and arrays $value = []; } @@ -68,15 +83,28 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec private function isValueTypeConsistent(mixed $value, LivePropMetadata $livePropMetadata): bool { - $propType = $livePropMetadata->getType(); + if (null === $type = $livePropMetadata->getType()) { + return true; + } + + if ($type->isIdentifiedBy(TypeIdentifier::MIXED)) { + return true; + } - if ($livePropMetadata->allowsNull() && null === $value) { + if ($type->isNullable() && null === $value) { return true; } - return - \in_array($propType, [null, 'mixed']) - || $livePropMetadata->isBuiltIn() && ('\is_'.$propType)($value) - || !$livePropMetadata->isBuiltIn() && $value instanceof $propType; + while ($type instanceof WrappingTypeInterface) { + $type = $type->getWrappedType(); + } + + if ($type instanceof ObjectType) { + $className = $type->getClassName(); + + return $value instanceof $className; + } + + return ('is_'.$type)($value); } }