diff --git a/.gitattributes b/.gitattributes index 84c7add05..14c3c3594 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ /Tests export-ignore /phpunit.xml.dist export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..4689c4dad --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/.github/workflows/check-subtree-split.yml b/.github/workflows/check-subtree-split.yml new file mode 100644 index 000000000..16be48bae --- /dev/null +++ b/.github/workflows/check-subtree-split.yml @@ -0,0 +1,37 @@ +name: Check subtree split + +on: + pull_request_target: + +jobs: + close-pull-request: + runs-on: ubuntu-latest + + steps: + - name: Close pull request + uses: actions/github-script@v6 + with: + script: | + if (context.repo.owner === "symfony") { + github.rest.issues.createComment({ + owner: "symfony", + repo: context.repo.repo, + issue_number: context.issue.number, + body: ` + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! + ` + }); + + github.rest.pulls.update({ + owner: "symfony", + repo: context.repo.repo, + pull_number: context.issue.number, + state: "closed" + }); + } diff --git a/Context/Normalizer/AbstractNormalizerContextBuilder.php b/Context/Normalizer/AbstractNormalizerContextBuilder.php index ecb328dd6..f365ac8df 100644 --- a/Context/Normalizer/AbstractNormalizerContextBuilder.php +++ b/Context/Normalizer/AbstractNormalizerContextBuilder.php @@ -104,17 +104,25 @@ public function withAllowExtraAttributes(?bool $allowExtraAttributes): static } /** - * Configures an hashmap of classes containing hashmaps of constructor argument => default value. + * Configures a hashmap of classes containing hashmaps of constructor argument => default value. * * The names need to match the parameter names in the constructor arguments. * * Eg: [Foo::class => ['foo' => true, 'bar' => 0]] * - * @param array>|null $defaultContructorArguments + * @param array>|null $defaultConstructorArguments + */ + public function withDefaultConstructorArguments(?array $defaultConstructorArguments): static + { + return $this->with(AbstractNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS, $defaultConstructorArguments); + } + + /** + * Deprecated in Symfony 7.1, use withDefaultConstructorArguments() instead. */ public function withDefaultContructorArguments(?array $defaultContructorArguments): static { - return $this->with(AbstractNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS, $defaultContructorArguments); + return self::withDefaultConstructorArguments($defaultContructorArguments); } /** diff --git a/Mapping/Loader/AttributeLoader.php b/Mapping/Loader/AttributeLoader.php index 7643d9a56..ddb467164 100644 --- a/Mapping/Loader/AttributeLoader.php +++ b/Mapping/Loader/AttributeLoader.php @@ -125,7 +125,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $accessorOrMutator = preg_match('/^(get|is|has|set)(.+)$/i', $method->name, $matches); if ($accessorOrMutator) { - $attributeName = $reflectionClass->hasProperty($method->name) ? $method->name : lcfirst($matches[2]); + $attributeName = lcfirst($matches[2]); if (isset($attributesMetadata[$attributeName])) { $attributeMetadata = $attributesMetadata[$attributeName]; @@ -163,11 +163,9 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $attributeMetadata->setSerializedPath($annotation->getSerializedPath()); } elseif ($annotation instanceof Ignore) { - if (!$accessorOrMutator) { - throw new MappingException(sprintf('Ignore on "%s::%s()" cannot be added. Ignore can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); + if ($accessorOrMutator) { + $attributeMetadata->setIgnore(true); } - - $attributeMetadata->setIgnore(true); } elseif ($annotation instanceof Context) { if (!$accessorOrMutator) { throw new MappingException(sprintf('Context on "%s::%s()" cannot be added. Context can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); diff --git a/Normalizer/AbstractObjectNormalizer.php b/Normalizer/AbstractObjectNormalizer.php index 3d5e24adb..9400616bd 100644 --- a/Normalizer/AbstractObjectNormalizer.php +++ b/Normalizer/AbstractObjectNormalizer.php @@ -142,6 +142,8 @@ public function supportsNormalization(mixed $data, ?string $format = null, array public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { + $context['_read_attributes'] = true; + if (!isset($context['cache_key'])) { $context['cache_key'] = $this->getCacheKey($format, $context); } @@ -283,6 +285,8 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { + $context['_read_attributes'] = false; + if (!isset($context['cache_key'])) { $context['cache_key'] = $this->getCacheKey($format, $context); } @@ -293,6 +297,10 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return null; } + if (XmlEncoder::FORMAT === $format && !\is_array($data)) { + $data = ['#' => $data]; + } + $allowedAttributes = $this->getAllowedAttributes($type, $context, true); $normalizedData = $this->prepareForDenormalization($data); $extraAttributes = []; @@ -676,7 +684,7 @@ private function updateData(array $data, string $attribute, mixed $attributeValu private function isMaxDepthReached(array $attributesMetadata, string $class, string $attribute, array &$context): bool { if (!($enableMaxDepth = $context[self::ENABLE_MAX_DEPTH] ?? $this->defaultContext[self::ENABLE_MAX_DEPTH] ?? false) - || null === $maxDepth = $attributesMetadata[$attribute]?->getMaxDepth() + || !isset($attributesMetadata[$attribute]) || null === $maxDepth = $attributesMetadata[$attribute]?->getMaxDepth() ) { return false; } diff --git a/Normalizer/GetSetMethodNormalizer.php b/Normalizer/GetSetMethodNormalizer.php index e050bc153..33ca1effc 100644 --- a/Normalizer/GetSetMethodNormalizer.php +++ b/Normalizer/GetSetMethodNormalizer.php @@ -37,6 +37,7 @@ */ final class GetSetMethodNormalizer extends AbstractObjectNormalizer { + private static $reflectionCache = []; private static array $setterAccessibleCache = []; public function getSupportedTypes(?string $format): array @@ -46,27 +47,31 @@ public function getSupportedTypes(?string $format): array public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { - return parent::supportsNormalization($data, $format) && $this->supports($data::class); + return parent::supportsNormalization($data, $format) && $this->supports($data::class, true); } public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { - return parent::supportsDenormalization($data, $type, $format) && $this->supports($type); + return parent::supportsDenormalization($data, $type, $format) && $this->supports($type, false); } /** - * Checks if the given class has any getter method. + * Checks if the given class has any getter or setter method. */ - private function supports(string $class): bool + private function supports(string $class, bool $readAttributes): bool { if ($this->classDiscriminatorResolver?->getMappingForClass($class)) { return true; } - $class = new \ReflectionClass($class); - $methods = $class->getMethods(\ReflectionMethod::IS_PUBLIC); - foreach ($methods as $method) { - if ($this->isGetMethod($method)) { + if (!isset(self::$reflectionCache[$class])) { + self::$reflectionCache[$class] = new \ReflectionClass($class); + } + + $reflection = self::$reflectionCache[$class]; + + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflectionMethod) { + if ($readAttributes ? $this->isGetMethod($reflectionMethod) : $this->isSetMethod($reflectionMethod)) { return true; } } @@ -87,6 +92,17 @@ private function isGetMethod(\ReflectionMethod $method): bool ); } + /** + * Checks if a method's name matches /^set.+$/ and can be called non-statically with one parameter. + */ + private function isSetMethod(\ReflectionMethod $method): bool + { + return !$method->isStatic() + && !$method->getAttributes(Ignore::class) + && 1 === $method->getNumberOfRequiredParameters() + && str_starts_with($method->name, 'set'); + } + protected function extractAttributes(object $object, ?string $format = null, array $context = []): array { $reflectionObject = new \ReflectionObject($object); @@ -110,19 +126,17 @@ protected function extractAttributes(object $object, ?string $format = null, arr protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { - $ucfirsted = ucfirst($attribute); - - $getter = 'get'.$ucfirsted; + $getter = 'get'.$attribute; if (method_exists($object, $getter) && \is_callable([$object, $getter])) { return $object->$getter(); } - $isser = 'is'.$ucfirsted; + $isser = 'is'.$attribute; if (method_exists($object, $isser) && \is_callable([$object, $isser])) { return $object->$isser(); } - $haser = 'has'.$ucfirsted; + $haser = 'has'.$attribute; if (method_exists($object, $haser) && \is_callable([$object, $haser])) { return $object->$haser(); } @@ -132,7 +146,7 @@ protected function getAttributeValue(object $object, string $attribute, ?string protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void { - $setter = 'set'.ucfirst($attribute); + $setter = 'set'.$attribute; $key = $object::class.':'.$setter; if (!isset(self::$setterAccessibleCache[$key])) { @@ -143,4 +157,48 @@ protected function setAttributeValue(object $object, string $attribute, mixed $v $object->$setter($value); } } + + protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool + { + if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) { + return false; + } + + $class = \is_object($classOrObject) ? \get_class($classOrObject) : $classOrObject; + + if (!isset(self::$reflectionCache[$class])) { + self::$reflectionCache[$class] = new \ReflectionClass($class); + } + + $reflection = self::$reflectionCache[$class]; + + if ($context['_read_attributes'] ?? true) { + foreach (['get', 'is', 'has'] as $getterPrefix) { + $getter = $getterPrefix.$attribute; + $reflectionMethod = $reflection->hasMethod($getter) ? $reflection->getMethod($getter) : null; + if ($reflectionMethod && $this->isGetMethod($reflectionMethod)) { + return true; + } + } + + return false; + } + + $setter = 'set'.$attribute; + if ($reflection->hasMethod($setter) && $this->isSetMethod($reflection->getMethod($setter))) { + return true; + } + + $constructor = $reflection->getConstructor(); + + if ($constructor && $constructor->isPublic()) { + foreach ($constructor->getParameters() as $parameter) { + if ($parameter->getName() === $attribute) { + return true; + } + } + } + + return false; + } } diff --git a/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index 083d3dd3f..5aff910d6 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -14,7 +14,11 @@ use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyWriteInfo; +use Symfony\Component\Serializer\Annotation\Ignore; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; @@ -28,11 +32,14 @@ */ final class ObjectNormalizer extends AbstractObjectNormalizer { + private static $reflectionCache = []; + protected PropertyAccessorInterface $propertyAccessor; + protected $propertyInfoExtractor; private readonly \Closure $objectClassResolver; - public function __construct(?ClassMetadataFactoryInterface $classMetadataFactory = null, ?NameConverterInterface $nameConverter = null, ?PropertyAccessorInterface $propertyAccessor = null, ?PropertyTypeExtractorInterface $propertyTypeExtractor = null, ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, ?callable $objectClassResolver = null, array $defaultContext = []) + public function __construct(?ClassMetadataFactoryInterface $classMetadataFactory = null, ?NameConverterInterface $nameConverter = null, ?PropertyAccessorInterface $propertyAccessor = null, ?PropertyTypeExtractorInterface $propertyTypeExtractor = null, ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, ?callable $objectClassResolver = null, array $defaultContext = [], ?PropertyInfoExtractorInterface $propertyInfoExtractor = null) { if (!class_exists(PropertyAccess::class)) { throw new LogicException('The ObjectNormalizer class requires the "PropertyAccess" component. Try running "composer require symfony/property-access".'); @@ -43,6 +50,7 @@ public function __construct(?ClassMetadataFactoryInterface $classMetadataFactory $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); $this->objectClassResolver = ($objectClassResolver ?? static fn ($class) => \is_object($class) ? $class::class : $class)(...); + $this->propertyInfoExtractor = $propertyInfoExtractor ?: new ReflectionExtractor(); } public function getSupportedTypes(?string $format): array @@ -78,25 +86,17 @@ protected function extractAttributes(object $object, ?string $format = null, arr if (str_starts_with($name, 'get') || str_starts_with($name, 'has') || str_starts_with($name, 'can')) { // getters, hassers and canners - $attributeName = $name; + $attributeName = substr($name, 3); if (!$reflClass->hasProperty($attributeName)) { - $attributeName = substr($attributeName, 3); - - if (!$reflClass->hasProperty($attributeName)) { - $attributeName = lcfirst($attributeName); - } + $attributeName = lcfirst($attributeName); } } elseif (str_starts_with($name, 'is')) { // issers - $attributeName = $name; + $attributeName = substr($name, 2); if (!$reflClass->hasProperty($attributeName)) { - $attributeName = substr($attributeName, 2); - - if (!$reflClass->hasProperty($attributeName)) { - $attributeName = lcfirst($attributeName); - } + $attributeName = lcfirst($attributeName); } } @@ -162,4 +162,38 @@ protected function getAllowedAttributes(string|object $classOrObject, array $con return $allowedAttributes; } + + protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool + { + if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) { + return false; + } + $class = \is_object($classOrObject) ? \get_class($classOrObject) : $classOrObject; + + if ($context['_read_attributes'] ?? true) { + return $this->propertyInfoExtractor->isReadable($class, $attribute) || $this->hasAttributeAccessorMethod($class, $attribute); + } + + return $this->propertyInfoExtractor->isWritable($class, $attribute) + || ($writeInfo = $this->propertyInfoExtractor->getWriteInfo($class, $attribute)) && PropertyWriteInfo::TYPE_NONE !== $writeInfo->getType(); + } + + private function hasAttributeAccessorMethod(string $class, string $attribute): bool + { + if (!isset(self::$reflectionCache[$class])) { + self::$reflectionCache[$class] = new \ReflectionClass($class); + } + + $reflection = self::$reflectionCache[$class]; + + if (!$reflection->hasMethod($attribute)) { + return false; + } + + $method = $reflection->getMethod($attribute); + + return !$method->isStatic() + && !$method->getAttributes(Ignore::class) + && !$method->getNumberOfRequiredParameters(); + } } diff --git a/SerializerAwareTrait.php b/SerializerAwareTrait.php index e4ba0ecb9..495e5889c 100644 --- a/SerializerAwareTrait.php +++ b/SerializerAwareTrait.php @@ -16,7 +16,7 @@ */ trait SerializerAwareTrait { - protected SerializerInterface $serializer; + protected ?SerializerInterface $serializer = null; public function setSerializer(SerializerInterface $serializer): void { diff --git a/Tests/Context/Normalizer/AbstractNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/AbstractNormalizerContextBuilderTest.php index 158fa8fea..4b8f0cc3f 100644 --- a/Tests/Context/Normalizer/AbstractNormalizerContextBuilderTest.php +++ b/Tests/Context/Normalizer/AbstractNormalizerContextBuilderTest.php @@ -41,7 +41,7 @@ public function testWithers(array $values) ->withGroups($values[AbstractNormalizer::GROUPS]) ->withAttributes($values[AbstractNormalizer::ATTRIBUTES]) ->withAllowExtraAttributes($values[AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES]) - ->withDefaultContructorArguments($values[AbstractNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS]) + ->withDefaultConstructorArguments($values[AbstractNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS]) ->withCallbacks($values[AbstractNormalizer::CALLBACKS]) ->withCircularReferenceHandler($values[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER]) ->withIgnoredAttributes($values[AbstractNormalizer::IGNORED_ATTRIBUTES]) diff --git a/Tests/Fixtures/SamePropertyAsMethodDummy.php b/Tests/Fixtures/SamePropertyAsMethodDummy.php deleted file mode 100644 index 89c8fcb9c..000000000 --- a/Tests/Fixtures/SamePropertyAsMethodDummy.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Serializer\Tests\Fixtures; - -class SamePropertyAsMethodDummy -{ - private $freeTrial; - private $hasSubscribe; - private $getReady; - private $isActive; - - public function __construct($freeTrial, $hasSubscribe, $getReady, $isActive) - { - $this->freeTrial = $freeTrial; - $this->hasSubscribe = $hasSubscribe; - $this->getReady = $getReady; - $this->isActive = $isActive; - } - - public function getFreeTrial() - { - return $this->freeTrial; - } - - public function hasSubscribe() - { - return $this->hasSubscribe; - } - - public function getReady() - { - return $this->getReady; - } - - public function isActive() - { - return $this->isActive; - } -} diff --git a/Tests/Fixtures/SamePropertyAsMethodWithMethodSerializedNameDummy.php b/Tests/Fixtures/SamePropertyAsMethodWithMethodSerializedNameDummy.php deleted file mode 100644 index 203118885..000000000 --- a/Tests/Fixtures/SamePropertyAsMethodWithMethodSerializedNameDummy.php +++ /dev/null @@ -1,54 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Serializer\Tests\Fixtures; - -use Symfony\Component\Serializer\Annotation\SerializedName; - -class SamePropertyAsMethodWithMethodSerializedNameDummy -{ - private $freeTrial; - private $hasSubscribe; - private $getReady; - private $isActive; - - public function __construct($freeTrial, $hasSubscribe, $getReady, $isActive) - { - $this->freeTrial = $freeTrial; - $this->hasSubscribe = $hasSubscribe; - $this->getReady = $getReady; - $this->isActive = $isActive; - } - - #[SerializedName('free_trial_method')] - public function getFreeTrial() - { - return $this->freeTrial; - } - - #[SerializedName('has_subscribe_method')] - public function hasSubscribe() - { - return $this->hasSubscribe; - } - - #[SerializedName('get_ready_method')] - public function getReady() - { - return $this->getReady; - } - - #[SerializedName('is_active_method')] - public function isActive() - { - return $this->isActive; - } -} diff --git a/Tests/Fixtures/SamePropertyAsMethodWithPropertySerializedNameDummy.php b/Tests/Fixtures/SamePropertyAsMethodWithPropertySerializedNameDummy.php deleted file mode 100644 index 0b681934f..000000000 --- a/Tests/Fixtures/SamePropertyAsMethodWithPropertySerializedNameDummy.php +++ /dev/null @@ -1,57 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Serializer\Tests\Fixtures; - -use Symfony\Component\Serializer\Annotation\SerializedName; - -class SamePropertyAsMethodWithPropertySerializedNameDummy -{ - #[SerializedName('free_trial_property')] - private $freeTrial; - - #[SerializedName('has_subscribe_property')] - private $hasSubscribe; - - #[SerializedName('get_ready_property')] - private $getReady; - - #[SerializedName('is_active_property')] - private $isActive; - - public function __construct($freeTrial, $hasSubscribe, $getReady, $isActive) - { - $this->freeTrial = $freeTrial; - $this->hasSubscribe = $hasSubscribe; - $this->getReady = $getReady; - $this->isActive = $isActive; - } - - public function getFreeTrial() - { - return $this->freeTrial; - } - - public function hasSubscribe() - { - return $this->hasSubscribe; - } - - public function getReady() - { - return $this->getReady; - } - - public function isActive() - { - return $this->isActive; - } -} diff --git a/Tests/Mapping/Loader/AttributeLoaderTest.php b/Tests/Mapping/Loader/AttributeLoaderTest.php index f2353c8fa..a76595506 100644 --- a/Tests/Mapping/Loader/AttributeLoaderTest.php +++ b/Tests/Mapping/Loader/AttributeLoaderTest.php @@ -173,13 +173,12 @@ public function testThrowsOnContextOnInvalidMethod() public function testCanHandleUnrelatedIgnoredMethods() { - $this->expectException(MappingException::class); - $this->expectExceptionMessage(sprintf('Ignore on "%s::badIgnore()" cannot be added', Entity45016::class)); - $metadata = new ClassMetadata(Entity45016::class); $loader = $this->getLoaderForContextMapping(); $loader->loadClassMetadata($metadata); + + $this->assertSame(['id'], array_keys($metadata->getAttributesMetadata())); } public function testIgnoreGetterWithRequiredParameterIfIgnoreAnnotationIsUsed() diff --git a/Tests/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index d92f50e22..3922a37e9 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -1030,6 +1030,53 @@ protected function createChildContext(array $parentContext, string $attribute, ? $this->assertFalse($normalizer->childContextCacheKey); } + + public function testDenormalizeXmlScalar() + { + $normalizer = new class () extends AbstractObjectNormalizer + { + public function __construct() + { + parent::__construct(null, new MetadataAwareNameConverter(new ClassMetadataFactory(new AttributeLoader()))); + } + + protected function extractAttributes(object $object, ?string $format = null, array $context = []): array + { + return []; + } + + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed + { + return null; + } + + protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []): void + { + $object->$attribute = $value; + } + + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + }; + + $this->assertSame('scalar', $normalizer->denormalize('scalar', XmlScalarDummy::class, 'xml')->value); + } + + public function testNormalizationWithMaxDepthOnStdclassObjectDoesNotThrowWarning() + { + $object = new \stdClass(); + $object->string = 'yes'; + + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory); + $normalized = $normalizer->normalize($object, context: [ + AbstractObjectNormalizer::ENABLE_MAX_DEPTH => true, + ]); + + $this->assertSame(['string' => 'yes'], $normalized); + } } class AbstractObjectNormalizerDummy extends AbstractObjectNormalizer @@ -1292,6 +1339,12 @@ class DummyChild public $bar; } +class XmlScalarDummy +{ + #[SerializedName('#')] + public $value; +} + class SerializerCollectionDummy implements SerializerInterface, DenormalizerInterface { private array $normalizers; diff --git a/Tests/Normalizer/GetSetMethodNormalizerTest.php b/Tests/Normalizer/GetSetMethodNormalizerTest.php index e3e887379..bed1577de 100644 --- a/Tests/Normalizer/GetSetMethodNormalizerTest.php +++ b/Tests/Normalizer/GetSetMethodNormalizerTest.php @@ -526,6 +526,23 @@ public function testDenormalizeWithDiscriminator() $this->assertEquals($denormalized, $normalizer->denormalize(['type' => 'two', 'url' => 'url'], GetSetMethodDummyInterface::class)); } + + public function testSupportsAndNormalizeWithOnlyParentGetter() + { + $obj = new GetSetDummyChild(); + $obj->setFoo('foo'); + + $this->assertTrue($this->normalizer->supportsNormalization($obj)); + $this->assertSame(['foo' => 'foo'], $this->normalizer->normalize($obj)); + } + + public function testSupportsAndDenormalizeWithOnlyParentSetter() + { + $this->assertTrue($this->normalizer->supportsDenormalization(['foo' => 'foo'], GetSetDummyChild::class)); + + $obj = $this->normalizer->denormalize(['foo' => 'foo'], GetSetDummyChild::class); + $this->assertSame('foo', $obj->getFoo()); + } } class GetSetDummy @@ -828,3 +845,22 @@ public function setUrl(string $url): void $this->url = $url; } } + +class GetSetDummyChild extends GetSetDummyParent +{ +} + +class GetSetDummyParent +{ + private $foo; + + public function getFoo() + { + return $this->foo; + } + + public function setFoo($foo) + { + $this->foo = $foo; + } +} diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index 8285dd88e..fbe3d64d9 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -18,6 +18,7 @@ use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\Serializer\Attribute\Ignore; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -41,9 +42,6 @@ use Symfony\Component\Serializer\Tests\Fixtures\Php74Dummy; use Symfony\Component\Serializer\Tests\Fixtures\Php74DummyPrivate; use Symfony\Component\Serializer\Tests\Fixtures\Php80Dummy; -use Symfony\Component\Serializer\Tests\Fixtures\SamePropertyAsMethodDummy; -use Symfony\Component\Serializer\Tests\Fixtures\SamePropertyAsMethodWithMethodSerializedNameDummy; -use Symfony\Component\Serializer\Tests\Fixtures\SamePropertyAsMethodWithPropertySerializedNameDummy; use Symfony\Component\Serializer\Tests\Fixtures\SiblingHolder; use Symfony\Component\Serializer\Tests\Normalizer\Features\AttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CacheableObjectAttributesTestTrait; @@ -839,51 +837,39 @@ public function testNormalizeStdClass() $this->assertSame(['baz' => 'baz'], $this->normalizer->normalize($o2)); } - public function testSamePropertyAsMethod() + public function testNormalizeWithoutSerializerSet() { - $object = new SamePropertyAsMethodDummy('free_trial', 'has_subscribe', 'get_ready', 'is_active'); - $expected = [ - 'freeTrial' => 'free_trial', - 'hasSubscribe' => 'has_subscribe', - 'getReady' => 'get_ready', - 'isActive' => 'is_active', - ]; + $normalizer = new ObjectNormalizer(new ClassMetadataFactory(new AttributeLoader())); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot normalize attribute "foo" because the injected serializer is not a normalizer.'); - $this->assertSame($expected, $this->normalizer->normalize($object)); + $normalizer->normalize(new ObjectConstructorDummy([], [], [])); } - public function testSamePropertyAsMethodWithPropertySerializedName() + public function testNormalizeWithIgnoreAttributeAndPrivateProperties() { $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); - $this->normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); - $this->normalizer->setSerializer($this->serializer); - - $object = new SamePropertyAsMethodWithPropertySerializedNameDummy('free_trial', 'has_subscribe', 'get_ready', 'is_active'); - $expected = [ - 'free_trial_property' => 'free_trial', - 'has_subscribe_property' => 'has_subscribe', - 'get_ready_property' => 'get_ready', - 'is_active_property' => 'is_active', - ]; + $normalizer = new ObjectNormalizer($classMetadataFactory); - $this->assertSame($expected, $this->normalizer->normalize($object)); + $this->assertSame(['foo' => 'foo'], $normalizer->normalize(new ObjectDummyWithIgnoreAttributeAndPrivateProperty())); } - public function testSamePropertyAsMethodWithMethodSerializedName() + public function testDenormalizeWithIgnoreAttributeAndPrivateProperties() { $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); - $this->normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); - $this->normalizer->setSerializer($this->serializer); + $normalizer = new ObjectNormalizer($classMetadataFactory); - $object = new SamePropertyAsMethodWithMethodSerializedNameDummy('free_trial', 'has_subscribe', 'get_ready', 'is_active'); - $expected = [ - 'free_trial_method' => 'free_trial', - 'has_subscribe_method' => 'has_subscribe', - 'get_ready_method' => 'get_ready', - 'is_active_method' => 'is_active', - ]; + $obj = $normalizer->denormalize([ + 'foo' => 'set', + 'ignore' => 'set', + 'private' => 'set', + ], ObjectDummyWithIgnoreAttributeAndPrivateProperty::class); + + $expected = new ObjectDummyWithIgnoreAttributeAndPrivateProperty(); + $expected->foo = 'set'; - $this->assertSame($expected, $this->normalizer->normalize($object)); + $this->assertEquals($expected, $obj); } } @@ -1060,7 +1046,7 @@ public function __get($name) } } - public function __isset($name) + public function __isset($name): bool { return 'foo' === $name; } @@ -1157,3 +1143,13 @@ public function getInner() return $this->inner; } } + +class ObjectDummyWithIgnoreAttributeAndPrivateProperty +{ + public $foo = 'foo'; + + #[Ignore] + public $ignored = 'ignored'; + + private $private = 'private'; +}