diff --git a/.github/workflows/.utils.sh b/.github/workflows/.utils.sh index 78b0d277984..e4f14caf089 100644 --- a/.github/workflows/.utils.sh +++ b/.github/workflows/.utils.sh @@ -19,3 +19,19 @@ _run_task() { exit $ok } export -f _run_task + +install_property_info_for_version() { + local php_version="$1" + local min_stability="$2" + + if [ "$php_version" = "8.2" ]; then + composer require symfony/property-info:7.1.* symfony/type-info:7.2.* + elif [ "$php_version" = "8.3" ]; then + composer require symfony/property-info:7.2.* symfony/type-info:7.2.* + elif [ "$php_version" = "8.4" ] && [ "$min_stability" = "stable" ]; then + composer require symfony/property-info:7.3.* symfony/type-info:7.3.* + elif [ "$php_version" = "8.4" ] && [ "$min_stability" = "dev" ]; then + composer require symfony/property-info:>=7.3 symfony/type-info:>=7.3 + fi +} +export -f install_property_info_for_version diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 655b6ccef83..7891d8c39a2 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -71,7 +71,12 @@ jobs: run: | source .github/workflows/.utils.sh - echo "$PACKAGES" | xargs -n1 | parallel -j +3 "_run_task {} '(cd src/{} && $COMPOSER_MIN_STAB && $COMPOSER_UP && $PHPUNIT)'" + echo "$PACKAGES" | xargs -n1 | parallel -j +3 "_run_task {} \ + '(cd src/{} \ + && $COMPOSER_MIN_STAB \ + && $COMPOSER_UP \ + && if [ {} = LiveComponent ]; then install_property_info_for_version \"${{ matrix.php-version }}\" \"${{ matrix.minimum-stability }}\"; fi \ + && $PHPUNIT)'" js: runs-on: ubuntu-latest diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 82a4a7a1de3..699db588d47 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -3,6 +3,8 @@ ## 2.26.0 - `LiveProp`: Pass the property name as second parameter of the `modifier` callable +- Add compatibility layer to fix deprecation with `Symfony\Component\PropertyInfo\PropertyInfoExtractor::getTypes()`. + If you use PHP 8.2 or higher, we recommend you to update dependency `symfony/property-info` to at least 7.1.0 ## 2.25.0 diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json index 8f3e4d9b8db..645c549e9ee 100644 --- a/src/LiveComponent/composer.json +++ b/src/LiveComponent/composer.json @@ -56,7 +56,9 @@ "zenstruck/foundry": "^2.0" }, "conflict": { - "symfony/config": "<5.4.0" + "symfony/config": "<5.4.0", + "symfony/type-info": "<7.1", + "symfony/property-info": "~7.0.0" }, "config": { "sort-packages": true diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index e458571cc13..290b07bb5dc 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -484,6 +484,17 @@ library. Make sure it is installed in you application: $ composer require phpdocumentor/reflection-docblock +.. versionadded:: 2.26 + + Support for `Symfony TypeInfo`_ component was added in LiveComponents 2.26. + +To get rid of deprecations about ``PropertyInfoExtractor::getTypes()`` from the `Symfony PropertyInfo`_ component, +ensure to upgrade ``symfony/property-info`` to at least 7.1, which requires **PHP 8.2**:: + +.. code-block:: terminal + + $ composer require symfony/property-info:^7.1 + Writable Object Properties or Array Keys ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -3900,3 +3911,5 @@ promise. However, any internal implementation in the JavaScript files .. _`setting the locale in the request`: https://symfony.com/doc/current/translation.html#translation-locale .. _`Stimulus action parameter`: https://stimulus.hotwired.dev/reference/actions#action-parameters .. _`@symfony/ux-live-component npm package`: https://www.npmjs.com/package/@symfony/ux-live-component +.. _`Symfony TypeInfo`: https://symfony.com/doc/current/components/type_info.html +.. _`Symfony PropertyInfo`: https://symfony.com/doc/current/components/property_info.html diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php index b0014ab19df..71aecd31704 100644 --- a/src/LiveComponent/src/LiveComponentHydrator.php +++ b/src/LiveComponent/src/LiveComponentHydrator.php @@ -18,19 +18,26 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Type as LegacyType; 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\TypeIdentifier; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Symfony\Component\Uid\AbstractUid; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Exception\HydrationException; use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface; +use Symfony\UX\LiveComponent\Metadata\LegacyLivePropMetadata; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; use Symfony\UX\LiveComponent\Metadata\LivePropMetadata; use Symfony\UX\LiveComponent\Util\DehydratedProps; +use Symfony\UX\LiveComponent\Util\TypeHelper; use Symfony\UX\TwigComponent\ComponentAttributes; use Twig\Environment; use Twig\Runtime\EscaperRuntime; @@ -249,7 +256,7 @@ public function hydrate(object $component, array $props, array $updatedProps, Li * * @throws SerializerExceptionInterface */ - public function hydrateValue(mixed $value, LivePropMetadata $propMetadata, object $parentObject): mixed + public function hydrateValue(mixed $value, LivePropMetadata|LegacyLivePropMetadata $propMetadata, object $parentObject): mixed { if ($propMetadata->hydrateMethod()) { if (!method_exists($parentObject, $propMetadata->hydrateMethod())) { @@ -270,37 +277,65 @@ 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().'[]'; + // BC layer when "symfony/type-info" is not available + if ($propMetadata instanceof LegacyLivePropMetadata) { + if ($propMetadata->collectionValueType()) { + $builtInType = $propMetadata->collectionValueType()->getBuiltinType(); + if (LegacyType::BUILTIN_TYPE_OBJECT === $builtInType) { + $type = $propMetadata->collectionValueType()->getClassName().'[]'; + } else { + $type = $builtInType.'[]'; + } } else { - $type = $builtInType.'[]'; + $type = $propMetadata->getType(); + } + + 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 (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 (null === $value && $propMetadata->allowsNull()) { + return null; + } + + return $this->serializer->denormalize($value, $type, 'json', $propMetadata->serializationContext()); } else { $type = $propMetadata->getType(); - } - 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 (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 (null === $value && $propMetadata->allowsNull()) { - return null; - } + if (null === $value && $type->isNullable()) { + return null; + } - return $this->serializer->denormalize($value, $type, 'json', $propMetadata->serializationContext()); - } + $isCollection = false; + foreach (TypeHelper::traverse($type) as $t) { + if ($t instanceof CollectionType) { + $isCollection = true; + $type = $t->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); + break; + } + } + + 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 (!$type = $propMetadata->getType()) { return $value; } @@ -308,16 +343,59 @@ 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()); - } + // BC layer when "symfony/type-info" is not available + if ($propMetadata instanceof LegacyLivePropMetadata) { + if ($propMetadata->collectionValueType() && LegacyType::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); + } + } + + if (\is_string($value) && $propMetadata->isBuiltIn() && \in_array($propMetadata->getType(), ['int', 'float', 'bool'], true)) { + return self::coerceStringValue($value, $propMetadata->getType(), $propMetadata->allowsNull()); + } + + // for all other built-ins: int, boolean, array, return as is + if ($propMetadata->isBuiltIn()) { + return $value; + } + + return $this->hydrateObjectValue($value, $propMetadata->getType(), $propMetadata->allowsNull(), $propMetadata->getFormat(), $parentObject::class, $propMetadata->getName(), $parentObject); + } else { + $collectionValueType = null; + foreach (TypeHelper::traverse($type) as $t) { + if ($t instanceof CollectionType) { + $collectionValueType = $t->getCollectionValueType(); + + break; + } + } + + if ($collectionValueType) { + foreach (TypeHelper::traverse($collectionValueType) as $t) { + if ($t instanceof ObjectType) { + foreach ($value as $key => $objectItem) { + $value[$key] = $this->hydrateObjectValue($objectItem, $t->getClassName(), true, $propMetadata->getFormat(), $parentObject::class, \sprintf('%s.%s', $propMetadata->getName(), $key), $parentObject); + } + + break; + } + } + } + + if (\is_string($value) && $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT, TypeIdentifier::BOOL)) { + return self::coerceStringValue($value, $type, $type->isNullable()); + } + + foreach (TypeHelper::traverse($type) as $t) { + if ($t instanceof ObjectType) { + return $this->hydrateObjectValue($value, $t->getClassName(), $type->isNullable(), $propMetadata->getFormat(), $parentObject::class, $propMetadata->getName(), $parentObject); + } + } - // for all other built-ins: int, boolean, array, return as is - if ($propMetadata->isBuiltIn()) { return $value; } - - return $this->hydrateObjectValue($value, $propMetadata->getType(), $propMetadata->allowsNull(), $propMetadata->getFormat(), $parentObject::class, $propMetadata->getName(), $parentObject); } public function addChecksumToData(array $data): array @@ -327,18 +405,28 @@ 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, string|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), + // BC layer when "symfony/type-info" is not available + if (\is_string($type)) { + return match ($type) { + 'int' => (int) $value, + 'float' => (float) $value, + 'bool' => self::coerceStringToBoolean($value), + default => throw new \LogicException(\sprintf('Cannot coerce value "%s" to type "%s"', $value, $type)), + }; + } + + 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)), }; } @@ -442,7 +530,7 @@ private function setWritablePaths(array $writablePaths, string $frontendPropName return $propertyValue; } - private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, object $parentObject): mixed + private function dehydrateValue(mixed $value, LivePropMetadata|LegacyLivePropMetadata $propMetadata, object $parentObject): mixed { if ($method = $propMetadata->dehydrateMethod()) { if (!method_exists($parentObject, $method)) { @@ -471,14 +559,41 @@ private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, ob } 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))); + // BC layer when "symfony/type-info" is not available + if ($propMetadata instanceof LegacyLivePropMetadata) { + if ($propMetadata->collectionValueType() && LegacyType::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))); + } + + $value[$key] = $this->dehydrateObjectValue($objectItem, $collectionClass, $propMetadata->getFormat(), $parentObject); } + } + } else { + $collectionValueType = null; + + foreach ($propMetadata->getType() ? TypeHelper::traverse($propMetadata->getType()) : [] as $t) { + if ($t instanceof CollectionType) { + $collectionValueType = $t->getCollectionValueType(); + + break; + } + } + + foreach ($collectionValueType ? TypeHelper::traverse($collectionValueType) : [] as $t) { + if ($t instanceof ObjectType) { + foreach ($value as $key => $objectItem) { + if (!TypeHelper::accepts($t, $objectItem)) { + 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, $t->getClassName(), get_debug_type($objectItem))); + } - $value[$key] = $this->dehydrateObjectValue($objectItem, $collectionClass, $propMetadata->getFormat(), $parentObject); + $value[$key] = $this->dehydrateObjectValue($objectItem, $t->getClassName(), $propMetadata->getFormat(), $parentObject); + } + + break; + } } } @@ -493,14 +608,25 @@ 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)); - } + // BC layer when "symfony/type-info" is not available + if ($propMetadata instanceof LegacyLivePropMetadata) { + 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)); + } + + // at this point, we have an object and can assume $propMetadata->getType() + // is set correctly (needed for hydration later) - // at this point, we have an object and can assume $propMetadata->getType() - // is set correctly (needed for hydration later) + return $this->dehydrateObjectValue($value, $propMetadata->getType(), $propMetadata->getFormat(), $parentObject); + } else { + foreach ($propMetadata->getType() ? TypeHelper::traverse($propMetadata->getType()) : [] as $t) { + if ($t instanceof ObjectType) { + return $this->dehydrateObjectValue($value, $t->getClassName(), $propMetadata->getFormat(), $parentObject); + } + } - return $this->dehydrateObjectValue($value, $propMetadata->getType(), $propMetadata->getFormat(), $parentObject); + 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)); + } } private function dehydrateObjectValue(object $value, string $classType, ?string $dateFormat, object $parentObject): mixed @@ -625,7 +751,7 @@ private function isValueValidDehydratedValue(mixed $value): bool * we need to set the "name" key on the "options" array, even if "name" * isn't explicitly a writable path. */ - private function calculateWritablePaths(LivePropMetadata $propMetadata, mixed $propertyValue, DehydratedProps $props, string $frontendPropName, string $componentClass): array + private function calculateWritablePaths(LivePropMetadata|LegacyLivePropMetadata $propMetadata, mixed $propertyValue, DehydratedProps $props, string $frontendPropName, string $componentClass): array { $writablePaths = $propMetadata->writablePaths(); if (\is_array($propertyValue) && $propMetadata->isIdentityWritable()) { @@ -684,7 +810,7 @@ private function ensureOnUpdatedMethodExists(object $component, string $methodNa * A special hook that will be called if the LiveProp was changed * and $onUpdated argument is set on its attribute. */ - private function processOnUpdatedHook(object $component, string $frontendName, LivePropMetadata $propMetadata, DehydratedProps $dehydratedUpdatedProps, DehydratedProps $dehydratedOriginalProps): void + private function processOnUpdatedHook(object $component, string $frontendName, LivePropMetadata|LegacyLivePropMetadata $propMetadata, DehydratedProps $dehydratedUpdatedProps, DehydratedProps $dehydratedOriginalProps): void { $onUpdated = $propMetadata->onUpdated(); if (\is_string($onUpdated)) { diff --git a/src/LiveComponent/src/Metadata/LegacyLivePropMetadata.php b/src/LiveComponent/src/Metadata/LegacyLivePropMetadata.php new file mode 100644 index 00000000000..a05d90cc523 --- /dev/null +++ b/src/LiveComponent/src/Metadata/LegacyLivePropMetadata.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Metadata; + +use Symfony\Component\PropertyInfo\Type; +use Symfony\UX\LiveComponent\Attribute\LiveProp; + +/** + * @author Kevin Bond + * + * @internal + */ +final class LegacyLivePropMetadata +{ + public function __construct( + private string $name, + private LiveProp $liveProp, + private ?string $typeName, + private bool $isBuiltIn, + private bool $allowsNull, + private ?Type $collectionValueType, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getType(): ?string + { + return $this->typeName; + } + + public function isBuiltIn(): bool + { + return $this->isBuiltIn; + } + + public function allowsNull(): bool + { + return $this->allowsNull; + } + + public function urlMapping(): ?UrlMapping + { + return $this->liveProp->url() ?: null; + } + + public function calculateFieldName(object $component, string $fallback): string + { + return $this->liveProp->calculateFieldName($component, $fallback); + } + + /** + * @return array + */ + public function writablePaths(): array + { + return $this->liveProp->writablePaths(); + } + + public function hydrateMethod(): ?string + { + return $this->liveProp->hydrateMethod(); + } + + public function dehydrateMethod(): ?string + { + return $this->liveProp->dehydrateMethod(); + } + + public function isIdentityWritable(): bool + { + return $this->liveProp->isIdentityWritable(); + } + + public function acceptUpdatesFromParent(): bool + { + return $this->liveProp->acceptUpdatesFromParent(); + } + + public function useSerializerForHydration(): bool + { + return $this->liveProp->useSerializerForHydration(); + } + + public function serializationContext(): array + { + return $this->liveProp->serializationContext(); + } + + public function collectionValueType(): ?Type + { + return $this->collectionValueType; + } + + public function getFormat(): ?string + { + return $this->liveProp->format(); + } + + public function onUpdated(): string|array|null + { + return $this->liveProp->onUpdated(); + } + + public function hasModifier(): bool + { + return null !== $this->liveProp->modifier(); + } + + /** + * Applies a modifier specified in LiveProp attribute. + * + * If a modifier is specified, a modified clone is returned. + * Otherwise, the metadata is returned as it is. + */ + public function withModifier(object $component): self + { + if (null === ($modifier = $this->liveProp->modifier())) { + return $this; + } + + if (!method_exists($component, $modifier)) { + throw new \LogicException(\sprintf('Method "%s::%s()" given in LiveProp "modifier" does not exist.', $component::class, $modifier)); + } + + $modifiedLiveProp = $component->{$modifier}($this->liveProp, $this->getName()); + if (!$modifiedLiveProp instanceof LiveProp) { + throw new \LogicException(\sprintf('Method "%s::%s()" should return an instance of "%s" (given: "%s").', $component::class, $modifier, LiveProp::class, get_debug_type($modifiedLiveProp))); + } + + $clone = clone $this; + $clone->liveProp = $modifiedLiveProp; + + return $clone; + } +} diff --git a/src/LiveComponent/src/Metadata/LiveComponentMetadata.php b/src/LiveComponent/src/Metadata/LiveComponentMetadata.php index 667449151f3..cae3c580760 100644 --- a/src/LiveComponent/src/Metadata/LiveComponentMetadata.php +++ b/src/LiveComponent/src/Metadata/LiveComponentMetadata.php @@ -22,12 +22,12 @@ class LiveComponentMetadata { public function __construct( private ComponentMetadata $componentMetadata, - /** @var LivePropMetadata[] */ + /** @var list */ private array $livePropsMetadata, ) { uasort( $this->livePropsMetadata, - static fn (LivePropMetadata $a, LivePropMetadata $b) => $a->hasModifier() <=> $b->hasModifier() + static fn (LivePropMetadata|LegacyLivePropMetadata $a, LivePropMetadata|LegacyLivePropMetadata $b) => $a->hasModifier() <=> $b->hasModifier() ); } @@ -37,7 +37,7 @@ public function getComponentMetadata(): ComponentMetadata } /** - * @return LivePropMetadata[] + * @return list */ public function getAllLivePropsMetadata(object $component): iterable { @@ -55,7 +55,7 @@ public function getAllLivePropsMetadata(object $component): iterable */ public function getOnlyPropsThatAcceptUpdatesFromParent(array $inputProps): array { - $writableProps = array_filter($this->livePropsMetadata, function (LivePropMetadata $livePropMetadata) { + $writableProps = array_filter($this->livePropsMetadata, function (LivePropMetadata|LegacyLivePropMetadata $livePropMetadata) { return $livePropMetadata->acceptUpdatesFromParent(); }); diff --git a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php index 78fd36ab836..fddff53a9ed 100644 --- a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php +++ b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php @@ -12,7 +12,10 @@ namespace Symfony\UX\LiveComponent\Metadata; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Type as LegacyType; +use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\NullableType; +use Symfony\Component\TypeInfo\Type\UnionType; use Symfony\Contracts\Service\ResetInterface; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\TwigComponent\ComponentFactory; @@ -48,7 +51,7 @@ public function getMetadata(string $name): LiveComponentMetadata } /** - * @return LivePropMetadata[] + * @return list * * @internal */ @@ -72,43 +75,54 @@ public function createPropMetadatas(\ReflectionClass $class): array return array_values($metadatas); } - public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, LiveProp $liveProp): LivePropMetadata + public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, LiveProp $liveProp): LivePropMetadata|LegacyLivePropMetadata { - $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())); - } + // BC layer when "symfony/type-info" is not available + if (!method_exists($this->propertyTypeExtractor, 'getType')) { + $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())); + } - $infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? []; + $infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? []; - $collectionValueType = null; - foreach ($infoTypes as $infoType) { - if ($infoType->isCollection()) { - foreach ($infoType->getCollectionValueTypes() as $valueType) { - $collectionValueType = $valueType; - break; + $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(); + if (null === $type && null === $collectionValueType && isset($infoTypes[0])) { + $infoType = LegacyType::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; + } + + return new LegacyLivePropMetadata( + $property->getName(), + $liveProp, + $infoType, + $isTypeBuiltIn, + $isTypeNullable, + $collectionValueType + ); } else { - $infoType = $type?->getName(); - $isTypeBuiltIn = $type?->isBuiltin() ?? false; - $isTypeNullable = $type?->allowsNull() ?? true; - } + $type = $this->propertyTypeExtractor->getType($className, $property->getName()); + + 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 5fe8c154065..86d8fc84954 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..e67741fd966 100644 --- a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php +++ b/src/LiveComponent/src/Util/QueryStringPropsExtractor.php @@ -12,8 +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\TypeIdentifier; use Symfony\UX\LiveComponent\Exception\HydrationException; use Symfony\UX\LiveComponent\LiveComponentHydrator; +use Symfony\UX\LiveComponent\Metadata\LegacyLivePropMetadata; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; use Symfony\UX\LiveComponent\Metadata\LivePropMetadata; @@ -44,9 +48,19 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec 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())) { - // Cast empty string to empty array for objects and arrays - $value = []; + if ('' === $value) { + // BC layer when "symfony/type-info" is not available + if ($livePropMetadata instanceof LegacyLivePropMetadata) { + if (!$livePropMetadata->isBuiltIn() || 'array' === $livePropMetadata->getType()) { + $value = []; + } + } else { + $type = $livePropMetadata->getType(); + if (null !== $type && (!$type->isSatisfiedBy(fn (Type $t): bool => $t instanceof BuiltinType) || $type->isIdentifiedBy(TypeIdentifier::ARRAY))) { + // Cast empty string to empty array for objects and arrays + $value = []; + } + } } try { @@ -66,17 +80,24 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec return $data; } - private function isValueTypeConsistent(mixed $value, LivePropMetadata $livePropMetadata): bool + private function isValueTypeConsistent(mixed $value, LivePropMetadata|LegacyLivePropMetadata $livePropMetadata): bool { - $propType = $livePropMetadata->getType(); + // BC layer when "symfony/type-info" is not available + if ($livePropMetadata instanceof LegacyLivePropMetadata) { + $propType = $livePropMetadata->getType(); - if ($livePropMetadata->allowsNull() && null === $value) { - return true; - } + if ($livePropMetadata->allowsNull() && null === $value) { + return true; + } - return - \in_array($propType, [null, 'mixed']) - || $livePropMetadata->isBuiltIn() && ('\is_'.$propType)($value) - || !$livePropMetadata->isBuiltIn() && $value instanceof $propType; + return + \in_array($propType, [null, 'mixed']) + || $livePropMetadata->isBuiltIn() && ('\is_'.$propType)($value) + || !$livePropMetadata->isBuiltIn() && $value instanceof $propType; + } else { + $type = $livePropMetadata->getType(); + + return null === $type || TypeHelper::accepts($type, $value); + } } } diff --git a/src/LiveComponent/src/Util/TypeHelper.php b/src/LiveComponent/src/Util/TypeHelper.php new file mode 100644 index 00000000000..b31ce99b777 --- /dev/null +++ b/src/LiveComponent/src/Util/TypeHelper.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Util; + +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * @author Hugo Alliaume + * + * @internal + */ +final class TypeHelper +{ + /** + * TODO: To remove when supporting symfony/type-info >=7.3 only. + */ + public static function accepts(Type $type, mixed $value): bool + { + $parentAccepts = function (Type $_type0, mixed $_value0): bool { + $specification = static function (Type $_type1) use (&$specification, $_value0): bool { + if ($_type1 instanceof WrappingTypeInterface) { + return $_type1->wrappedTypeIsSatisfiedBy($specification); + } + + if ($_type1 instanceof CompositeTypeInterface) { + return $_type1->composedTypesAreSatisfiedBy($specification); + } + + return TypeHelper::accepts($_type1, $_value0); + }; + + return $_type0->isSatisfiedBy($specification); + }; + + if ($type instanceof Type\ArrayShapeType) { + if (!\is_array($value)) { + return false; + } + + foreach ($type->getShape() as $key => $shapeValue) { + if (!($shapeValue['optional'] ?? false) && !\array_key_exists($key, $value)) { + return false; + } + } + + foreach ($value as $key => $itemValue) { + $valueType = $type->getShape()[$key]['type'] ?? false; + + if ($valueType && !TypeHelper::accepts($valueType, $itemValue)) { + return false; + } + + if (!$valueType && ($type->isSealed() || !TypeHelper::accepts($type->getExtraKeyType(), $key) || !TypeHelper::accepts($type->getExtraValueType(), $itemValue))) { + return false; + } + } + + return true; + } + + // Also supports EnumType and BackedEnumType + if ($type instanceof Type\ObjectType) { + $className = $type->getClassName(); + return $value instanceof $className; + } + + if ($type instanceof Type\BuiltinType) { + return match ($type->getTypeIdentifier()) { + TypeIdentifier::ARRAY => \is_array($value), + TypeIdentifier::BOOL => \is_bool($value), + TypeIdentifier::CALLABLE => \is_callable($value), + TypeIdentifier::FALSE => false === $value, + TypeIdentifier::FLOAT => \is_float($value), + TypeIdentifier::INT => \is_int($value), + TypeIdentifier::ITERABLE => is_iterable($value), + TypeIdentifier::MIXED => true, + TypeIdentifier::NULL => null === $value, + TypeIdentifier::OBJECT => \is_object($value), + TypeIdentifier::RESOURCE => \is_resource($value), + TypeIdentifier::STRING => \is_string($value), + TypeIdentifier::TRUE => true === $value, + default => false, + }; + } + + if ($type instanceof Type\CollectionType) { + if (!$parentAccepts($type, $value)) { + return false; + } + + if ($type->isList() && (!\is_array($value) || !array_is_list($value))) { + return false; + } + + $keyType = $type->getCollectionKeyType(); + $valueType = $type->getCollectionValueType(); + + if (is_iterable($value)) { + foreach ($value as $k => $v) { + // key or value do not match + if (!TypeHelper::accepts($keyType, $k) || !TypeHelper::accepts($valueType, $v)) { + return false; + } + } + } + + return true; + } + + if ($type instanceof Type\NullableType) { + return null === $value || $parentAccepts($type, $value); + } + + if ($type instanceof Type\GenericType || $type instanceof Type\TemplateType || $type instanceof Type\UnionType || $type instanceof Type\IntersectionType) { + return $parentAccepts($type, $value); + } + + return false; + } + + /** + * TODO: To remove when supporting symfony/type-info >=7.3 only. + * + * Traverses the whole type tree. + * + * @return iterable + */ + public static function traverse(Type $type, bool $traverseComposite = true, bool $traverseWrapped = true): iterable + { + if (method_exists($type, 'traverse')) { + yield from $type->traverse($traverseComposite, $traverseWrapped); + } else { + yield $type; + + if ($type instanceof CompositeTypeInterface && $traverseComposite) { + foreach ($type->getTypes() as $innerType) { + yield $innerType; + } + + // prevent yielding twice when having a type that is both composite and wrapped + return; + } + + if ($type instanceof WrappingTypeInterface && $traverseWrapped) { + yield $type->getWrappedType(); + } + } + } +} diff --git a/src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php b/src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php index 2ddf1666dc0..583f4245675 100644 --- a/src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php +++ b/src/LiveComponent/tests/Unit/LiveComponentHydratorTest.php @@ -16,8 +16,10 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; +use Symfony\Component\TypeInfo\Type; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\LiveComponentHydrator; +use Symfony\UX\LiveComponent\Metadata\LegacyLivePropMetadata; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; use Symfony\UX\LiveComponent\Metadata\LivePropMetadata; use Twig\Environment; @@ -41,22 +43,42 @@ public function testConstructWithEmptySecret(): void public function testItCanHydrateWithNullValues() { - $hydrator = new LiveComponentHydrator( - [], - $this->createMock(PropertyAccessorInterface::class), - $this->createMock(LiveComponentMetadataFactory::class), - new Serializer(normalizers: [new ObjectNormalizer()]), - 'foo', - $this->createMock(Environment::class), - ); + // BC layer when "symfony/type-info" is not available + if (!class_exists(Type::class)) { + $hydrator = new LiveComponentHydrator( + [], + $this->createMock(PropertyAccessorInterface::class), + $this->createMock(LiveComponentMetadataFactory::class), + new Serializer(normalizers: [new ObjectNormalizer()]), + 'foo', + $this->createMock(Environment::class), + ); - $hydratedValue = $hydrator->hydrateValue( - null, - new LivePropMetadata('foo', new LiveProp(useSerializerForHydration: true), typeName: Foo::class, isBuiltIn: false, allowsNull: true, collectionValueType: null), - parentObject: new \stdClass() // not relevant in this test - ); + $hydratedValue = $hydrator->hydrateValue( + null, + new LegacyLivePropMetadata('foo', new LiveProp(useSerializerForHydration: true), typeName: Foo::class, isBuiltIn: false, allowsNull: true, collectionValueType: null), + parentObject: new \stdClass() // not relevant in this test + ); + + self::assertNull($hydratedValue); + } else { + $hydrator = new LiveComponentHydrator( + [], + $this->createMock(PropertyAccessorInterface::class), + $this->createMock(LiveComponentMetadataFactory::class), + new Serializer(normalizers: [new ObjectNormalizer()]), + 'foo', + $this->createMock(Environment::class), + ); + + $hydratedValue = $hydrator->hydrateValue( + null, + new LivePropMetadata('foo', new LiveProp(useSerializerForHydration: true), Type::nullable(Type::object(Foo::class))), + parentObject: new \stdClass() // not relevant in this test + ); - self::assertNull($hydratedValue); + self::assertNull($hydratedValue); + } } }