From c5f0a98b193b9321814d41884b9c6d288d67b5d9 Mon Sep 17 00:00:00 2001 From: Jordi Kroon Date: Mon, 29 Dec 2025 14:38:07 +0100 Subject: [PATCH 1/7] [PropertyAccess][PropertyInfo][Serializer] Skip methods that look like getters but return void or never --- Mapping/Loader/AttributeLoader.php | 5 ++- Normalizer/GetSetMethodNormalizer.php | 3 +- Normalizer/ObjectNormalizer.php | 12 +++-- .../Attributes/AccessorishGetters.php | 2 +- Tests/Fixtures/VoidNeverReturnTypeDummy.php | 45 +++++++++++++++++++ .../AttributeLoaderWithAttributesTest.php | 12 +++++ .../Normalizer/GetSetMethodNormalizerTest.php | 11 +++++ Tests/Normalizer/ObjectNormalizerTest.php | 11 +++++ 8 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 Tests/Fixtures/VoidNeverReturnTypeDummy.php diff --git a/Mapping/Loader/AttributeLoader.php b/Mapping/Loader/AttributeLoader.php index 6a0ae669aaa..27d317094ab 100644 --- a/Mapping/Loader/AttributeLoader.php +++ b/Mapping/Loader/AttributeLoader.php @@ -130,13 +130,14 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool } $accessorOrMutator = match ($name[0]) { - 's' => str_starts_with($name, 'set') && isset($name[$i = 3]), + 's' => str_starts_with($name, 'set') && isset($name[$i = 3]) && $method->getNumberOfParameters(), 'g' => str_starts_with($name, 'get') && isset($name[$i = 3]), 'h' => str_starts_with($name, 'has') && isset($name[$i = 3]), 'c' => str_starts_with($name, 'can') && isset($name[$i = 3]), 'i' => str_starts_with($name, 'is') && isset($name[$i = 2]), default => false, - }; + } && ('s' === $name[0] || !$method->getNumberOfRequiredParameters() && !\in_array((string) $method->getReturnType(), ['void', 'never'], true)); + if ($accessorOrMutator && !ctype_lower($name[$i])) { if ($this->hasProperty($method->getDeclaringClass(), $name)) { $attributeName = $name; diff --git a/Normalizer/GetSetMethodNormalizer.php b/Normalizer/GetSetMethodNormalizer.php index b654eb82b07..c8aadd4669d 100644 --- a/Normalizer/GetSetMethodNormalizer.php +++ b/Normalizer/GetSetMethodNormalizer.php @@ -105,8 +105,9 @@ private function isGetMethod(\ReflectionMethod $method): bool return !$method->isStatic() && !($method->getAttributes(Ignore::class) || $method->getAttributes(LegacyIgnore::class)) && !$method->getNumberOfRequiredParameters() + && !\in_array((string) $method->getReturnType(), ['void', 'never'], true) && ((2 < ($methodLength = \strlen($method->name)) && str_starts_with($method->name, 'is') && !ctype_lower($method->name[2])) - || (3 < $methodLength && (str_starts_with($method->name, 'has') || str_starts_with($method->name, 'get')) && !ctype_lower($method->name[3])) + || (3 < $methodLength && (str_starts_with($method->name, 'has') || str_starts_with($method->name, 'get') || str_starts_with($method->name, 'can')) && !ctype_lower($method->name[3])) ); } diff --git a/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index aed3801191b..412f0cbc668 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -88,10 +88,11 @@ protected function extractAttributes(object $object, ?string $format = null, arr foreach ($reflClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflMethod) { if ( - 0 !== $reflMethod->getNumberOfRequiredParameters() + $reflMethod->getNumberOfRequiredParameters() || $reflMethod->isStatic() || $reflMethod->isConstructor() || $reflMethod->isDestructor() + || \in_array((string) $reflMethod->getReturnType(), ['void', 'never'], true) ) { continue; } @@ -198,11 +199,7 @@ protected function isAllowedAttribute($classOrObject, string $attribute, ?string private function hasAttributeAccessorMethod(string $class, string $attribute): bool { - if (!isset(self::$reflectionCache[$class])) { - self::$reflectionCache[$class] = new \ReflectionClass($class); - } - - $reflection = self::$reflectionCache[$class]; + $reflection = self::$reflectionCache[$class] ??= new \ReflectionClass($class); if (!$reflection->hasMethod($attribute)) { return false; @@ -212,6 +209,7 @@ private function hasAttributeAccessorMethod(string $class, string $attribute): b return !$method->isStatic() && !$method->getAttributes(Ignore::class) - && !$method->getNumberOfRequiredParameters(); + && !$method->getNumberOfRequiredParameters() + && !\in_array((string) $method->getReturnType(), ['void', 'never'], true); } } diff --git a/Tests/Fixtures/Attributes/AccessorishGetters.php b/Tests/Fixtures/Attributes/AccessorishGetters.php index f434e84f33d..80cdb8abea0 100644 --- a/Tests/Fixtures/Attributes/AccessorishGetters.php +++ b/Tests/Fixtures/Attributes/AccessorishGetters.php @@ -33,7 +33,7 @@ public function hasField3() { } - public function setField4() + public function setField4($value) { } } diff --git a/Tests/Fixtures/VoidNeverReturnTypeDummy.php b/Tests/Fixtures/VoidNeverReturnTypeDummy.php new file mode 100644 index 00000000000..eb083d449e8 --- /dev/null +++ b/Tests/Fixtures/VoidNeverReturnTypeDummy.php @@ -0,0 +1,45 @@ + + * + * 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 VoidNeverReturnTypeDummy +{ + public string $normalProperty = 'value'; + + public function getNormalProperty(): string + { + return $this->normalProperty; + } + + public function getVoidProperty(): void + { + // This looks like a getter but returns void, should be ignored + } + + public function getNeverProperty(): never + { + // This looks like a getter but returns never, should be ignored + throw new \Exception('Never returns'); + } + + public function setValue(): void + { + // This looks like a setter but has no parameters, should be ignored as accessor + } + + public function setNeverValue(): never + { + // This looks like a setter but has no parameters and returns never, should be ignored as accessor + throw new \Exception('Never returns'); + } +} + diff --git a/Tests/Mapping/Loader/AttributeLoaderWithAttributesTest.php b/Tests/Mapping/Loader/AttributeLoaderWithAttributesTest.php index 6f14d2fc06e..f0e3f452fd7 100644 --- a/Tests/Mapping/Loader/AttributeLoaderWithAttributesTest.php +++ b/Tests/Mapping/Loader/AttributeLoaderWithAttributesTest.php @@ -14,6 +14,7 @@ use Symfony\Component\Serializer\Exception\MappingException; use Symfony\Component\Serializer\Mapping\ClassMetadata; use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\Tests\Fixtures\VoidNeverReturnTypeDummy; class AttributeLoaderWithAttributesTest extends AttributeLoaderTestCase { @@ -36,4 +37,15 @@ public function testLoadWithInvalidAttribute() $this->loader->loadClassMetadata($classMetadata); } + + public function testSkipVoidNeverReturnTypeAccessors() + { + $classMetadata = new ClassMetadata(VoidNeverReturnTypeDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertArrayHasKey('normalProperty', $attributesMetadata); + $this->assertArrayNotHasKey('voidProperty', $attributesMetadata); + $this->assertArrayNotHasKey('neverProperty', $attributesMetadata); + } } diff --git a/Tests/Normalizer/GetSetMethodNormalizerTest.php b/Tests/Normalizer/GetSetMethodNormalizerTest.php index a3bdad8f82a..72a56de9d1a 100644 --- a/Tests/Normalizer/GetSetMethodNormalizerTest.php +++ b/Tests/Normalizer/GetSetMethodNormalizerTest.php @@ -36,6 +36,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\ScalarNormalizer; use Symfony\Component\Serializer\Tests\Fixtures\SiblingHolder; use Symfony\Component\Serializer\Tests\Fixtures\StdClassNormalizer; +use Symfony\Component\Serializer\Tests\Fixtures\VoidNeverReturnTypeDummy; use Symfony\Component\Serializer\Tests\Normalizer\Features\CacheableObjectAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CallbacksTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CircularReferenceTestTrait; @@ -620,6 +621,16 @@ public function testDiscriminatorWithAllowExtraAttributesFalse() $this->assertInstanceOf(GetSetMethodDiscriminatedDummyOne::class, $obj); } + + public function testSkipVoidNeverReturnTypeAccessors() + { + $obj = new VoidNeverReturnTypeDummy(); + $normalized = $this->normalizer->normalize($obj); + $this->assertArrayHasKey('normalProperty', $normalized); + $this->assertArrayNotHasKey('voidProperty', $normalized); + $this->assertArrayNotHasKey('neverProperty', $normalized); + $this->assertEquals('value', $normalized['normalProperty']); + } } class GetSetDummy diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index be066525ef9..b970db18fb7 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -50,6 +50,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\Php80Dummy; use Symfony\Component\Serializer\Tests\Fixtures\SiblingHolder; use Symfony\Component\Serializer\Tests\Fixtures\StdClassNormalizer; +use Symfony\Component\Serializer\Tests\Fixtures\VoidNeverReturnTypeDummy; use Symfony\Component\Serializer\Tests\Normalizer\Features\AttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CacheableObjectAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CallbacksTestTrait; @@ -1182,6 +1183,16 @@ public function testNormalizeObjectWithGroupsAndIsPrefixedProperty() $normalizedWithGroups = $normalizer->normalize($object, null, [AbstractNormalizer::GROUPS => ['test']]); $this->assertArrayHasKey('isSomething', $normalizedWithGroups); } + + public function testSkipVoidNeverReturnTypeAccessors() + { + $obj = new VoidNeverReturnTypeDummy(); + $normalized = $this->normalizer->normalize($obj); + $this->assertArrayHasKey('normalProperty', $normalized); + $this->assertArrayNotHasKey('voidProperty', $normalized); + $this->assertArrayNotHasKey('neverProperty', $normalized); + $this->assertEquals('value', $normalized['normalProperty']); + } } class ProxyObjectDummy extends ObjectDummy From 84930a3fe5891c54904fa9dfe2d519516164469b Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 22 Dec 2025 16:28:53 +0100 Subject: [PATCH 2/7] Allow serialization of method with same name than property --- Mapping/Loader/AttributeLoader.php | 17 ++++++++------- Normalizer/ObjectNormalizer.php | 2 ++ Tests/Normalizer/ObjectNormalizerTest.php | 26 +++++++++++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/Mapping/Loader/AttributeLoader.php b/Mapping/Loader/AttributeLoader.php index 27d317094ab..57451a5855c 100644 --- a/Mapping/Loader/AttributeLoader.php +++ b/Mapping/Loader/AttributeLoader.php @@ -138,8 +138,9 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool default => false, } && ('s' === $name[0] || !$method->getNumberOfRequiredParameters() && !\in_array((string) $method->getReturnType(), ['void', 'never'], true)); - if ($accessorOrMutator && !ctype_lower($name[$i])) { - if ($this->hasProperty($method->getDeclaringClass(), $name)) { + $hasProperty = $this->hasProperty($method->getDeclaringClass(), $name); + if ($hasProperty || $accessorOrMutator && !ctype_lower($name[$i])) { + if ($hasProperty) { $attributeName = $name; } else { $attributeName = substr($name, $i); @@ -159,7 +160,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool foreach ($this->loadAttributes($method) as $annotation) { if ($annotation instanceof Groups) { - if (!$accessorOrMutator) { + if (!$accessorOrMutator && !$hasProperty) { throw new MappingException(\sprintf('Groups on "%s::%s()" cannot be added. Groups can only be added on methods beginning with "get", "is", "has", "can" or "set".', $className, $method->name)); } @@ -167,29 +168,29 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $attributeMetadata->addGroup($group); } } elseif ($annotation instanceof MaxDepth) { - if (!$accessorOrMutator) { + if (!$accessorOrMutator && !$hasProperty) { throw new MappingException(\sprintf('MaxDepth on "%s::%s()" cannot be added. MaxDepth can only be added on methods beginning with "get", "is", "has", "can" or "set".', $className, $method->name)); } $attributeMetadata->setMaxDepth($annotation->getMaxDepth()); } elseif ($annotation instanceof SerializedName) { - if (!$accessorOrMutator) { + if (!$accessorOrMutator && !$hasProperty) { throw new MappingException(\sprintf('SerializedName on "%s::%s()" cannot be added. SerializedName can only be added on methods beginning with "get", "is", "has", "can" or "set".', $className, $method->name)); } $attributeMetadata->setSerializedName($annotation->getSerializedName()); } elseif ($annotation instanceof SerializedPath) { - if (!$accessorOrMutator) { + if (!$accessorOrMutator && !$hasProperty) { throw new MappingException(\sprintf('SerializedPath on "%s::%s()" cannot be added. SerializedPath can only be added on methods beginning with "get", "is", "has", "can" or "set".', $className, $method->name)); } $attributeMetadata->setSerializedPath($annotation->getSerializedPath()); } elseif ($annotation instanceof Ignore) { - if ($accessorOrMutator) { + if ($accessorOrMutator && !$hasProperty) { $attributeMetadata->setIgnore(true); } } elseif ($annotation instanceof Context) { - if (!$accessorOrMutator) { + if (!$accessorOrMutator && !$hasProperty) { throw new MappingException(\sprintf('Context on "%s::%s()" cannot be added. Context can only be added on methods beginning with "get", "is", "has", "can" or "set".', $className, $method->name)); } diff --git a/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index 412f0cbc668..619588b435f 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -117,6 +117,8 @@ protected function extractAttributes(object $object, ?string $format = null, arr $attributeName = lcfirst($attributeName); } } + } elseif ($this->hasProperty($reflMethod->getDeclaringClass(), $name)) { + $attributeName = $name; } if (null !== $attributeName && $this->isAllowedAttribute($object, $attributeName, $format, $context)) { diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index b970db18fb7..c51e70fcf90 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\Groups; use Symfony\Component\Serializer\Attribute\Ignore; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -1114,6 +1115,17 @@ public function testNormalizeChildWithPropertySameAsParentMethod() ], $normalized); } + public function testNormalizeObjectWithMethodSameNameAsProperty() + { + $normalizer = new ObjectNormalizer(new ClassMetadataFactory(new AttributeLoader())); + + $object = new ObjectWithMethodSameNameThanProperty(true); + + $this->assertSame(['shouldDoThing' => true], $normalizer->normalize($object)); + $this->assertSame(['shouldDoThing' => true], $normalizer->normalize($object, null, ['groups' => 'foo'])); + $this->assertSame([], $normalizer->normalize($object, null, ['groups' => 'bar'])); + } + /** * Priority of accessor methods is defined by the PropertyReadInfoExtractorInterface passed to the PropertyAccessor * component. By default ReflectionExtractor::$defaultAccessorPrefixes are used. @@ -1662,3 +1674,17 @@ public function hasFoo() return 'hasFoo'; } } + +class ObjectWithMethodSameNameThanProperty +{ + public function __construct( + private $shouldDoThing, + ) { + } + + #[Groups(['Default', 'foo'])] + public function shouldDoThing() + { + return $this->shouldDoThing; + } +} From 711ffd64ce348e2bc649c8afb267f2bc21bce46a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 6 Jan 2026 16:04:58 +0100 Subject: [PATCH 3/7] [Serializer] Fix #[Ignore] on same-name properties <-> methods --- Mapping/Loader/AttributeLoader.php | 4 +- Tests/Normalizer/ObjectNormalizerTest.php | 68 +++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/Mapping/Loader/AttributeLoader.php b/Mapping/Loader/AttributeLoader.php index 57451a5855c..2ee8e127563 100644 --- a/Mapping/Loader/AttributeLoader.php +++ b/Mapping/Loader/AttributeLoader.php @@ -186,9 +186,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $attributeMetadata->setSerializedPath($annotation->getSerializedPath()); } elseif ($annotation instanceof Ignore) { - if ($accessorOrMutator && !$hasProperty) { - $attributeMetadata->setIgnore(true); - } + $attributeMetadata->setIgnore(true); } elseif ($annotation instanceof Context) { if (!$accessorOrMutator && !$hasProperty) { throw new MappingException(\sprintf('Context on "%s::%s()" cannot be added. Context can only be added on methods beginning with "get", "is", "has", "can" or "set".', $className, $method->name)); diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index c51e70fcf90..99cbc6f1e5b 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -1126,6 +1126,28 @@ public function testNormalizeObjectWithMethodSameNameAsProperty() $this->assertSame([], $normalizer->normalize($object, null, ['groups' => 'bar'])); } + public function testIgnoreAttributeOnMethodWithSameNameAsProperty() + { + $normalizer = new ObjectNormalizer(new ClassMetadataFactory(new AttributeLoader())); + + $object = new ObjectWithIgnoredMethodSameNameAsProperty('should_be_ignored', 'should_be_serialized'); + + $this->assertSame(['visible' => 'should_be_serialized'], $normalizer->normalize($object)); + } + + public function testIgnoreAttributeOnMethodWithSameNameAsPropertyWithGroups() + { + $normalizer = new ObjectNormalizer(new ClassMetadataFactory(new AttributeLoader())); + + $object = new ObjectWithIgnoredMethodSameNameAsPropertyWithGroups('ignored', 'visible_default', 'visible_group'); + + // without groups - should include both visible properties + $this->assertSame(['visibleDefault' => 'visible_default', 'visibleGroup' => 'visible_group'], $normalizer->normalize($object)); + + // with groups - should only include group-specific property, ignored method should never appear + $this->assertSame(['visibleGroup' => 'visible_group'], $normalizer->normalize($object, null, ['groups' => ['group1']])); + } + /** * Priority of accessor methods is defined by the PropertyReadInfoExtractorInterface passed to the PropertyAccessor * component. By default ReflectionExtractor::$defaultAccessorPrefixes are used. @@ -1688,3 +1710,49 @@ public function shouldDoThing() return $this->shouldDoThing; } } + +class ObjectWithIgnoredMethodSameNameAsProperty +{ + public string $visible; + + private $ignored; + + public function __construct(string $ignored, string $visible) + { + $this->ignored = $ignored; + $this->visible = $visible; + } + + #[Ignore] + public function ignored() + { + return $this->ignored; + } +} + +class ObjectWithIgnoredMethodSameNameAsPropertyWithGroups +{ + public string $visibleDefault; + public string $visibleGroup; + + private $ignored; + + public function __construct(string $ignored, string $visibleDefault, string $visibleGroup) + { + $this->ignored = $ignored; + $this->visibleDefault = $visibleDefault; + $this->visibleGroup = $visibleGroup; + } + + #[Ignore] + public function ignored() + { + return $this->ignored; + } + + #[Groups(['group1'])] + public function visibleGroup() + { + return $this->visibleGroup; + } +} From c51cab264acdb7c683614d271ba765599d649570 Mon Sep 17 00:00:00 2001 From: Peter Gnodde Date: Mon, 5 Jan 2026 14:33:46 +0100 Subject: [PATCH 4/7] [Serializer] Fix removing nested values --- Normalizer/AbstractObjectNormalizer.php | 2 +- .../AbstractObjectNormalizerTest.php | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Normalizer/AbstractObjectNormalizer.php b/Normalizer/AbstractObjectNormalizer.php index 015699b759c..252ec777b34 100644 --- a/Normalizer/AbstractObjectNormalizer.php +++ b/Normalizer/AbstractObjectNormalizer.php @@ -859,7 +859,7 @@ private function getNestedAttributes(string $class): array private function removeNestedValue(array $path, array $data): array { $element = array_shift($path); - if (!$path || !$data[$element] = $this->removeNestedValue($path, $data[$element])) { + if (!$path || !$data[$element] || !$data[$element] = $this->removeNestedValue($path, $data[$element])) { unset($data[$element]); } diff --git a/Tests/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index d8c7d89a24b..c838ddac085 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyAccess\PropertyPath; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; @@ -873,6 +874,42 @@ public function testDenormalizeMissingAndNullNestedValues() $this->assertFalse((new \ReflectionProperty($obj, 'bar'))->isInitialized($obj)); } + public function testDenormalizeNullCoalescingValues() + { + if (!method_exists(PropertyPath::class, 'isNullSafe')) { + $this->markTestSkipped('null coalescing property path is not supported before symfony/property-access 6.2'); + } + + $normalizer = new AbstractObjectNormalizerWithMetadata(); + + $data = [ + 'data' => [ + 'foo' => 'test', + ], + 'empty_data' => null, + ]; + + $obj = new class { + #[SerializedPath('[data][foo?]')] + public ?string $foo; + + #[SerializedPath('[data][bar?]')] + public ?string $bar; + + #[SerializedPath('[empty_data?][nothing]')] + public ?string $nothing; + + #[SerializedPath('[not_set?][nothing]')] + public ?string $notSet; + }; + + $test = $normalizer->denormalize($data, $obj::class); + $this->assertSame('test', $test->foo); + $this->assertFalse((new \ReflectionProperty($obj, 'bar'))->isInitialized($obj)); + $this->assertNull($test->nothing); + $this->assertFalse((new \ReflectionProperty($obj, 'notSet'))->isInitialized($obj)); + } + public function testNormalizeBasedOnAllowedAttributes() { $normalizer = new class extends AbstractObjectNormalizer { From ff435302e318951fadaeae693b0c1eee5ba081e0 Mon Sep 17 00:00:00 2001 From: xersion22 Date: Mon, 12 Jan 2026 13:02:01 +0530 Subject: [PATCH 5/7] [Serializer] Fix NameConverter not detecting wrong input format with allow_extra_attributes=false --- Normalizer/AbstractObjectNormalizer.php | 10 ++++ Tests/Normalizer/ObjectNormalizerTest.php | 63 +++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/Normalizer/AbstractObjectNormalizer.php b/Normalizer/AbstractObjectNormalizer.php index 252ec777b34..b4b060a6346 100644 --- a/Normalizer/AbstractObjectNormalizer.php +++ b/Normalizer/AbstractObjectNormalizer.php @@ -358,6 +358,16 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a if (isset($nestedData[$notConverted]) && !isset($originalNestedData[$attribute])) { throw new LogicException(\sprintf('Duplicate values for key "%s" found. One value is set via the SerializedPath attribute: "%s", the other one is set via the SerializedName attribute: "%s".', $notConverted, implode('->', $nestedAttributes[$notConverted]->getElements()), $attribute)); } + + if ($attribute === $notConverted + && !($context[self::ALLOW_EXTRA_ATTRIBUTES] ?? $this->defaultContext[self::ALLOW_EXTRA_ATTRIBUTES]) + && (false === $allowedAttributes || \in_array($attribute, $allowedAttributes, true)) + && $this->nameConverter->normalize($attribute, $resolvedClass, $format, $context) !== $attribute + ) { + // Input was in wrong format (e.g., camelCase when snake_case expected) + $extraAttributes[] = $notConverted; + continue; + } } $attributeContext = $this->getAttributeDenormalizationContext($resolvedClass, $attribute, $context); diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index 99cbc6f1e5b..773355d2e3f 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -20,6 +20,7 @@ use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Ignore; +use Symfony\Component\Serializer\Exception\ExtraAttributesException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\RuntimeException; @@ -1202,6 +1203,51 @@ public function testDiscriminatorWithAllowExtraAttributesFalse() $this->assertInstanceOf(DiscriminatorDummyTypeA::class, $obj); } + public function testNameConverterWithWrongCaseAndAllowExtraAttributesFalse() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + + $result = $normalizer->denormalize( + ['some_camel_case_property' => 1], + NameConverterTestDummy::class, + null, + [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false] + ); + $this->assertSame(1, $result->someCamelCaseProperty); + + $this->expectException(ExtraAttributesException::class); + $this->expectExceptionMessage('someCamelCaseProperty'); + $normalizer->denormalize( + ['someCamelCaseProperty' => 1], + NameConverterTestDummy::class, + null, + [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false] + ); + } + + public function testNameConverterWithWrongCaseAndAllowExtraAttributesTrue() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + + $result = $normalizer->denormalize( + ['someCamelCaseProperty' => 999], + NameConverterTestDummy::class, + null, + [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true] + ); + $this->assertSame(0, $result->someCamelCaseProperty); + + $result = $normalizer->denormalize( + ['some_camel_case_property' => 42], + NameConverterTestDummy::class, + null, + [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true] + ); + $this->assertSame(42, $result->someCamelCaseProperty); + } + public function testNormalizeObjectWithGroupsAndIsPrefixedProperty() { $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); @@ -1756,3 +1802,20 @@ public function visibleGroup() return $this->visibleGroup; } } + +class NameConverterTestDummy +{ + public function __construct( + public readonly int $someCamelCaseProperty = 0, + ) { + } +} + +class NameConverterTestDummyMultiple +{ + public function __construct( + public readonly int $someCamelCaseProperty = 0, + public readonly int $anotherProperty = 0, + ) { + } +} From 43a730aa3e33d2059e0d1216bd913a46b6b3b962 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Fri, 9 Jan 2026 11:22:54 +0100 Subject: [PATCH 6/7] do not use PHPUnit mock objects without configured expectations --- .../CompiledClassMetadataCacheWarmerTest.php | 17 +-- Tests/Command/DebugCommandTest.php | 5 +- Tests/Debug/TraceableEncoderTest.php | 16 +-- Tests/Debug/TraceableNormalizerTest.php | 16 +-- Tests/Debug/TraceableSerializerTest.php | 2 +- Tests/Encoder/ChainDecoderTest.php | 111 +++++++++------- Tests/Encoder/ChainEncoderTest.php | 123 ++++++++++-------- .../Factory/CacheMetadataFactoryTest.php | 5 +- .../CompiledClassMetadataFactoryTest.php | 14 +- .../MetadataAwareNameConverterTest.php | 8 +- Tests/Normalizer/AbstractNormalizerTest.php | 8 +- .../AbstractObjectNormalizerTest.php | 6 +- Tests/Normalizer/ArrayDenormalizerTest.php | 37 +++--- Tests/Normalizer/FormErrorNormalizerTest.php | 12 +- .../Normalizer/GetSetMethodNormalizerTest.php | 2 +- .../JsonSerializableNormalizerTest.php | 47 +++---- Tests/Normalizer/ObjectNormalizerTest.php | 4 +- Tests/Normalizer/PropertyNormalizerTest.php | 4 +- .../Normalizer/TranslatableNormalizerTest.php | 10 +- ...est.php => UnwrappingDenormalizerTest.php} | 36 +++-- Tests/SerializerTest.php | 12 +- 21 files changed, 258 insertions(+), 237 deletions(-) rename Tests/Normalizer/{UnwrappinDenormalizerTest.php => UnwrappingDenormalizerTest.php} (62%) diff --git a/Tests/CacheWarmer/CompiledClassMetadataCacheWarmerTest.php b/Tests/CacheWarmer/CompiledClassMetadataCacheWarmerTest.php index 9d354270ed0..194c2c72033 100644 --- a/Tests/CacheWarmer/CompiledClassMetadataCacheWarmerTest.php +++ b/Tests/CacheWarmer/CompiledClassMetadataCacheWarmerTest.php @@ -15,35 +15,28 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; use Symfony\Component\Serializer\CacheWarmer\CompiledClassMetadataCacheWarmer; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryCompiler; -use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; final class CompiledClassMetadataCacheWarmerTest extends TestCase { public function testItImplementsCacheWarmerInterface() { - $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); - $filesystem = $this->createMock(Filesystem::class); - - $compiledClassMetadataCacheWarmer = new CompiledClassMetadataCacheWarmer([], $classMetadataFactory, new ClassMetadataFactoryCompiler(), $filesystem); + $compiledClassMetadataCacheWarmer = new CompiledClassMetadataCacheWarmer([], new ClassMetadataFactory(new AttributeLoader()), new ClassMetadataFactoryCompiler(), new Filesystem()); $this->assertInstanceOf(CacheWarmerInterface::class, $compiledClassMetadataCacheWarmer); } public function testItIsAnOptionalCacheWarmer() { - $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); - $filesystem = $this->createMock(Filesystem::class); - - $compiledClassMetadataCacheWarmer = new CompiledClassMetadataCacheWarmer([], $classMetadataFactory, new ClassMetadataFactoryCompiler(), $filesystem); + $compiledClassMetadataCacheWarmer = new CompiledClassMetadataCacheWarmer([], new ClassMetadataFactory(new AttributeLoader()), new ClassMetadataFactoryCompiler(), new Filesystem()); $this->assertTrue($compiledClassMetadataCacheWarmer->isOptional()); } public function testItDumpCompiledClassMetadatas() { - $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); - $code = <<with('/var/cache/prod/serializer.class.metadata.php', $code) ; - $compiledClassMetadataCacheWarmer = new CompiledClassMetadataCacheWarmer([], $classMetadataFactory, new ClassMetadataFactoryCompiler(), $filesystem); + $compiledClassMetadataCacheWarmer = new CompiledClassMetadataCacheWarmer([], new ClassMetadataFactory(new AttributeLoader()), new ClassMetadataFactoryCompiler(), $filesystem); $compiledClassMetadataCacheWarmer->warmUp('/var/cache/prod'); } diff --git a/Tests/Command/DebugCommandTest.php b/Tests/Command/DebugCommandTest.php index 879231160fe..1ff348f5a03 100644 --- a/Tests/Command/DebugCommandTest.php +++ b/Tests/Command/DebugCommandTest.php @@ -15,7 +15,6 @@ use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Serializer\Command\DebugCommand; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; -use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Tests\Dummy\DummyClassOne; @@ -79,9 +78,7 @@ public function testOutputWithClassArgument() public function testOutputWithInvalidClassArgument() { - $serializer = $this->createMock(ClassMetadataFactoryInterface::class); - - $command = new DebugCommand($serializer); + $command = new DebugCommand(new ClassMetadataFactory(new AttributeLoader())); $tester = new CommandTester($command); $tester->execute(['class' => 'App\\NotFoundResource'], ['decorated' => false]); diff --git a/Tests/Debug/TraceableEncoderTest.php b/Tests/Debug/TraceableEncoderTest.php index ec38c0ef529..4c1f8ca594d 100644 --- a/Tests/Debug/TraceableEncoderTest.php +++ b/Tests/Debug/TraceableEncoderTest.php @@ -42,8 +42,8 @@ public function testForwardsToEncoder() public function testCollectEncodingData() { - $encoder = $this->createMock(EncoderInterface::class); - $decoder = $this->createMock(DecoderInterface::class); + $encoder = $this->createStub(EncoderInterface::class); + $decoder = $this->createStub(DecoderInterface::class); $dataCollector = $this->createMock(SerializerDataCollector::class); $dataCollector @@ -61,8 +61,8 @@ public function testCollectEncodingData() public function testNotCollectEncodingDataIfNoDebugTraceId() { - $encoder = $this->createMock(EncoderInterface::class); - $decoder = $this->createMock(DecoderInterface::class); + $encoder = $this->createStub(EncoderInterface::class); + $decoder = $this->createStub(DecoderInterface::class); $dataCollector = $this->createMock(SerializerDataCollector::class); $dataCollector->expects($this->never())->method('collectEncoding'); @@ -76,22 +76,22 @@ public function testCannotEncodeIfNotEncoder() { $this->expectException(\BadMethodCallException::class); - (new TraceableEncoder($this->createMock(DecoderInterface::class), new SerializerDataCollector()))->encode('data', 'format'); + (new TraceableEncoder($this->createStub(DecoderInterface::class), new SerializerDataCollector()))->encode('data', 'format'); } public function testCannotDecodeIfNotDecoder() { $this->expectException(\BadMethodCallException::class); - (new TraceableEncoder($this->createMock(EncoderInterface::class), new SerializerDataCollector()))->decode('data', 'format'); + (new TraceableEncoder($this->createStub(EncoderInterface::class), new SerializerDataCollector()))->decode('data', 'format'); } public function testSupports() { - $encoder = $this->createMock(EncoderInterface::class); + $encoder = $this->createStub(EncoderInterface::class); $encoder->method('supportsEncoding')->willReturn(true); - $decoder = $this->createMock(DecoderInterface::class); + $decoder = $this->createStub(DecoderInterface::class); $decoder->method('supportsDecoding')->willReturn(true); $traceableEncoder = new TraceableEncoder($encoder, new SerializerDataCollector()); diff --git a/Tests/Debug/TraceableNormalizerTest.php b/Tests/Debug/TraceableNormalizerTest.php index 41e3441ed9b..1ebfa919838 100644 --- a/Tests/Debug/TraceableNormalizerTest.php +++ b/Tests/Debug/TraceableNormalizerTest.php @@ -44,9 +44,9 @@ public function testForwardsToNormalizer() public function testCollectNormalizationData() { - $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer = $this->createStub(NormalizerInterface::class); $normalizer->method('getSupportedTypes')->willReturn(['*' => false]); - $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer = $this->createStub(DenormalizerInterface::class); $denormalizer->method('getSupportedTypes')->willReturn(['*' => false]); $dataCollector = $this->createMock(SerializerDataCollector::class); @@ -65,9 +65,9 @@ public function testCollectNormalizationData() public function testNotCollectNormalizationDataIfNoDebugTraceId() { - $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer = $this->createStub(NormalizerInterface::class); $normalizer->method('getSupportedTypes')->willReturn(['*' => false]); - $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer = $this->createStub(DenormalizerInterface::class); $denormalizer->method('getSupportedTypes')->willReturn(['*' => false]); $dataCollector = $this->createMock(SerializerDataCollector::class); @@ -82,23 +82,23 @@ public function testCannotNormalizeIfNotNormalizer() { $this->expectException(\BadMethodCallException::class); - (new TraceableNormalizer($this->createMock(DenormalizerInterface::class), new SerializerDataCollector()))->normalize('data'); + (new TraceableNormalizer($this->createStub(DenormalizerInterface::class), new SerializerDataCollector()))->normalize('data'); } public function testCannotDenormalizeIfNotDenormalizer() { $this->expectException(\BadMethodCallException::class); - (new TraceableNormalizer($this->createMock(NormalizerInterface::class), new SerializerDataCollector()))->denormalize('data', 'type'); + (new TraceableNormalizer($this->createStub(NormalizerInterface::class), new SerializerDataCollector()))->denormalize('data', 'type'); } public function testSupports() { - $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer = $this->createStub(NormalizerInterface::class); $normalizer->method('getSupportedTypes')->willReturn(['*' => false]); $normalizer->method('supportsNormalization')->willReturn(true); - $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer = $this->createStub(DenormalizerInterface::class); $denormalizer->method('getSupportedTypes')->willReturn(['*' => false]); $denormalizer->method('supportsDenormalization')->willReturn(true); diff --git a/Tests/Debug/TraceableSerializerTest.php b/Tests/Debug/TraceableSerializerTest.php index dc6e4a6b7a1..b7ebd1422c0 100644 --- a/Tests/Debug/TraceableSerializerTest.php +++ b/Tests/Debug/TraceableSerializerTest.php @@ -106,7 +106,7 @@ public function testCollectData() public function testAddDebugTraceIdInContext() { - $serializer = $this->createMock(Serializer::class); + $serializer = $this->createStub(Serializer::class); foreach (['serialize', 'deserialize', 'normalize', 'denormalize', 'encode', 'decode'] as $method) { $serializer->method($method)->willReturnCallback(function (): string { diff --git a/Tests/Encoder/ChainDecoderTest.php b/Tests/Encoder/ChainDecoderTest.php index c3bbad2ab9e..f77f7285ecc 100644 --- a/Tests/Encoder/ChainDecoderTest.php +++ b/Tests/Encoder/ChainDecoderTest.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Serializer\Tests\Encoder; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Encoder\ChainDecoder; use Symfony\Component\Serializer\Encoder\ContextAwareDecoderInterface; @@ -24,72 +23,90 @@ class ChainDecoderTest extends TestCase private const FORMAT_2 = 'format2'; private const FORMAT_3 = 'format3'; - private ChainDecoder $chainDecoder; - private MockObject&ContextAwareDecoderInterface $decoder1; - private MockObject&DecoderInterface $decoder2; - - protected function setUp(): void - { - $this->decoder1 = $this->createMock(ContextAwareDecoderInterface::class); - $this->decoder1 - ->method('supportsDecoding') - ->willReturnMap([ - [self::FORMAT_1, [], true], - [self::FORMAT_2, [], false], - [self::FORMAT_3, [], false], - [self::FORMAT_3, ['foo' => 'bar'], true], - [self::FORMAT_3, ['foo' => 'bar2'], false], - ]); - - $this->decoder2 = $this->createMock(DecoderInterface::class); - $this->decoder2 - ->method('supportsDecoding') - ->willReturnMap([ - [self::FORMAT_1, [], false], - [self::FORMAT_2, [], true], - [self::FORMAT_3, [], false], - [self::FORMAT_3, ['foo' => 'bar'], false], - [self::FORMAT_3, ['foo' => 'bar2'], true], - ]); - - $this->chainDecoder = new ChainDecoder([$this->decoder1, $this->decoder2]); - } - public function testSupportsDecoding() { - $this->decoder1 + $decoder1 = $this->createDecoder1(); + $decoder1 ->method('decode') ->willReturn('result1'); - $this->decoder2 + $decoder2 = $this->createDecoder2(); + $decoder2 ->method('decode') ->willReturn('result2'); + $chainDecoder = new ChainDecoder([$decoder1, $decoder2]); - $this->assertTrue($this->chainDecoder->supportsDecoding(self::FORMAT_1)); - $this->assertEquals('result1', $this->chainDecoder->decode('', self::FORMAT_1, [])); + $this->assertTrue($chainDecoder->supportsDecoding(self::FORMAT_1)); + $this->assertEquals('result1', $chainDecoder->decode('', self::FORMAT_1, [])); - $this->assertTrue($this->chainDecoder->supportsDecoding(self::FORMAT_2)); - $this->assertEquals('result2', $this->chainDecoder->decode('', self::FORMAT_2, [])); + $this->assertTrue($chainDecoder->supportsDecoding(self::FORMAT_2)); + $this->assertEquals('result2', $chainDecoder->decode('', self::FORMAT_2, [])); - $this->assertFalse($this->chainDecoder->supportsDecoding(self::FORMAT_3)); + $this->assertFalse($chainDecoder->supportsDecoding(self::FORMAT_3)); - $this->assertTrue($this->chainDecoder->supportsDecoding(self::FORMAT_3, ['foo' => 'bar'])); - $this->assertEquals('result1', $this->chainDecoder->decode('', self::FORMAT_3, ['foo' => 'bar'])); + $this->assertTrue($chainDecoder->supportsDecoding(self::FORMAT_3, ['foo' => 'bar'])); + $this->assertEquals('result1', $chainDecoder->decode('', self::FORMAT_3, ['foo' => 'bar'])); - $this->assertTrue($this->chainDecoder->supportsDecoding(self::FORMAT_3, ['foo' => 'bar2'])); - $this->assertEquals('result2', $this->chainDecoder->decode('', self::FORMAT_3, ['foo' => 'bar2'])); + $this->assertTrue($chainDecoder->supportsDecoding(self::FORMAT_3, ['foo' => 'bar2'])); + $this->assertEquals('result2', $chainDecoder->decode('', self::FORMAT_3, ['foo' => 'bar2'])); } public function testDecode() { - $this->decoder1->expects($this->never())->method('decode'); - $this->decoder2->expects($this->once())->method('decode'); + $decoder1 = $this->createDecoder1(true); + $decoder1->expects($this->never())->method('decode'); + $decoder2 = $this->createDecoder2(true); + $decoder2->expects($this->once())->method('decode'); + $chainDecoder = new ChainDecoder([$decoder1, $decoder2]); - $this->chainDecoder->decode('string_to_decode', self::FORMAT_2); + $chainDecoder->decode('string_to_decode', self::FORMAT_2); } public function testDecodeUnsupportedFormat() { + $chainDecoder = new ChainDecoder([$this->createDecoder1(), $this->createDecoder2()]); $this->expectException(RuntimeException::class); - $this->chainDecoder->decode('string_to_decode', self::FORMAT_3); + $chainDecoder->decode('string_to_decode', self::FORMAT_3); + } + + private function createDecoder1(bool $mock = false): DecoderInterface + { + if ($mock) { + $decoder = $this->createMock(ContextAwareDecoderInterface::class); + } else { + $decoder = $this->createStub(ContextAwareDecoderInterface::class); + } + + $decoder + ->method('supportsDecoding') + ->willReturnMap([ + [self::FORMAT_1, [], true], + [self::FORMAT_2, [], false], + [self::FORMAT_3, [], false], + [self::FORMAT_3, ['foo' => 'bar'], true], + [self::FORMAT_3, ['foo' => 'bar2'], false], + ]); + + return $decoder; + } + + private function createDecoder2(bool $mock = false): DecoderInterface + { + if ($mock) { + $decoder = $this->createMock(DecoderInterface::class); + } else { + $decoder = $this->createStub(DecoderInterface::class); + } + + $decoder + ->method('supportsDecoding') + ->willReturnMap([ + [self::FORMAT_1, [], false], + [self::FORMAT_2, [], true], + [self::FORMAT_3, [], false], + [self::FORMAT_3, ['foo' => 'bar'], false], + [self::FORMAT_3, ['foo' => 'bar2'], true], + ]); + + return $decoder; } } diff --git a/Tests/Encoder/ChainEncoderTest.php b/Tests/Encoder/ChainEncoderTest.php index 375d08054ab..cb7048f780c 100644 --- a/Tests/Encoder/ChainEncoderTest.php +++ b/Tests/Encoder/ChainEncoderTest.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Serializer\Tests\Encoder; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Debug\TraceableEncoder; use Symfony\Component\Serializer\Encoder\ChainEncoder; @@ -26,79 +25,59 @@ class ChainEncoderTest extends TestCase private const FORMAT_2 = 'format2'; private const FORMAT_3 = 'format3'; - private ChainEncoder $chainEncoder; - private MockObject&ContextAwareEncoderInterface $encoder1; - private MockObject&EncoderInterface $encoder2; - - protected function setUp(): void - { - $this->encoder1 = $this->createMock(ContextAwareEncoderInterface::class); - $this->encoder1 - ->method('supportsEncoding') - ->willReturnMap([ - [self::FORMAT_1, [], true], - [self::FORMAT_2, [], false], - [self::FORMAT_3, [], false], - [self::FORMAT_3, ['foo' => 'bar'], true], - [self::FORMAT_3, ['foo' => 'bar2'], false], - ]); - - $this->encoder2 = $this->createMock(EncoderInterface::class); - $this->encoder2 - ->method('supportsEncoding') - ->willReturnMap([ - [self::FORMAT_1, [], false], - [self::FORMAT_2, [], true], - [self::FORMAT_3, [], false], - [self::FORMAT_3, ['foo' => 'bar'], false], - [self::FORMAT_3, ['foo' => 'bar2'], true], - ]); - - $this->chainEncoder = new ChainEncoder([$this->encoder1, $this->encoder2]); - } - public function testSupportsEncoding() { - $this->encoder1 + $encoder1 = $this->createEncoder1(); + $encoder1 ->method('encode') ->willReturn('result1'); - $this->encoder2 + $encoder2 = $this->createEncoder2(); + $encoder2 ->method('encode') ->willReturn('result2'); - $this->assertTrue($this->chainEncoder->supportsEncoding(self::FORMAT_1)); - $this->assertEquals('result1', $this->chainEncoder->encode('', self::FORMAT_1, [])); + $chainEncoder = new ChainEncoder([$encoder1, $encoder2]); + + $this->assertTrue($chainEncoder->supportsEncoding(self::FORMAT_1)); + $this->assertEquals('result1', $chainEncoder->encode('', self::FORMAT_1, [])); - $this->assertTrue($this->chainEncoder->supportsEncoding(self::FORMAT_2)); - $this->assertEquals('result2', $this->chainEncoder->encode('', self::FORMAT_2, [])); + $this->assertTrue($chainEncoder->supportsEncoding(self::FORMAT_2)); + $this->assertEquals('result2', $chainEncoder->encode('', self::FORMAT_2, [])); - $this->assertFalse($this->chainEncoder->supportsEncoding(self::FORMAT_3)); + $this->assertFalse($chainEncoder->supportsEncoding(self::FORMAT_3)); - $this->assertTrue($this->chainEncoder->supportsEncoding(self::FORMAT_3, ['foo' => 'bar'])); - $this->assertEquals('result1', $this->chainEncoder->encode('', self::FORMAT_3, ['foo' => 'bar'])); + $this->assertTrue($chainEncoder->supportsEncoding(self::FORMAT_3, ['foo' => 'bar'])); + $this->assertEquals('result1', $chainEncoder->encode('', self::FORMAT_3, ['foo' => 'bar'])); - $this->assertTrue($this->chainEncoder->supportsEncoding(self::FORMAT_3, ['foo' => 'bar2'])); - $this->assertEquals('result2', $this->chainEncoder->encode('', self::FORMAT_3, ['foo' => 'bar2'])); + $this->assertTrue($chainEncoder->supportsEncoding(self::FORMAT_3, ['foo' => 'bar2'])); + $this->assertEquals('result2', $chainEncoder->encode('', self::FORMAT_3, ['foo' => 'bar2'])); } public function testEncode() { - $this->encoder1->expects($this->never())->method('encode'); - $this->encoder2->expects($this->once())->method('encode')->willReturn('foo:123'); + $encoder1 = $this->createEncoder1(true); + $encoder1->expects($this->never())->method('encode'); + $encoder2 = $this->createEncoder2(true); + $encoder2->expects($this->once())->method('encode')->willReturn('foo:123'); - $this->assertSame('foo:123', $this->chainEncoder->encode(['foo' => 123], self::FORMAT_2)); + $chainEncoder = new ChainEncoder([$encoder1, $encoder2]); + + $this->assertSame('foo:123', $chainEncoder->encode(['foo' => 123], self::FORMAT_2)); } public function testEncodeUnsupportedFormat() { + $chainEncoder = new ChainEncoder([$this->createEncoder1(), $this->createEncoder2()]); $this->expectException(RuntimeException::class); - $this->chainEncoder->encode(['foo' => 123], self::FORMAT_3); + $chainEncoder->encode(['foo' => 123], self::FORMAT_3); } public function testNeedsNormalizationBasic() { - $this->assertTrue($this->chainEncoder->needsNormalization(self::FORMAT_1)); - $this->assertTrue($this->chainEncoder->needsNormalization(self::FORMAT_2)); + $chainEncoder = new ChainEncoder([$this->createEncoder1(), $this->createEncoder2()]); + + $this->assertTrue($chainEncoder->needsNormalization(self::FORMAT_1)); + $this->assertTrue($chainEncoder->needsNormalization(self::FORMAT_2)); } public function testNeedsNormalizationNormalizationAware() @@ -111,18 +90,60 @@ public function testNeedsNormalizationNormalizationAware() public function testNeedsNormalizationTraceableEncoder() { - $traceableEncoder = $this->createMock(TraceableEncoder::class); + $traceableEncoder = $this->createStub(TraceableEncoder::class); $traceableEncoder->method('needsNormalization')->willReturn(true); $traceableEncoder->method('supportsEncoding')->willReturn(true); $this->assertTrue((new ChainEncoder([$traceableEncoder]))->needsNormalization('format')); - $traceableEncoder = $this->createMock(TraceableEncoder::class); + $traceableEncoder = $this->createStub(TraceableEncoder::class); $traceableEncoder->method('needsNormalization')->willReturn(false); $traceableEncoder->method('supportsEncoding')->willReturn(true); $this->assertFalse((new ChainEncoder([$traceableEncoder]))->needsNormalization('format')); } + + private function createEncoder1(bool $mock = false): EncoderInterface + { + if ($mock) { + $encoder = $this->createMock(ContextAwareEncoderInterface::class); + } else { + $encoder = $this->createStub(ContextAwareEncoderInterface::class); + } + + $encoder + ->method('supportsEncoding') + ->willReturnMap([ + [self::FORMAT_1, [], true], + [self::FORMAT_2, [], false], + [self::FORMAT_3, [], false], + [self::FORMAT_3, ['foo' => 'bar'], true], + [self::FORMAT_3, ['foo' => 'bar2'], false], + ]); + + return $encoder; + } + + private function createEncoder2(bool $mock = false): EncoderInterface + { + if ($mock) { + $encoder = $this->createMock(EncoderInterface::class); + } else { + $encoder = $this->createStub(EncoderInterface::class); + } + + $encoder + ->method('supportsEncoding') + ->willReturnMap([ + [self::FORMAT_1, [], false], + [self::FORMAT_2, [], true], + [self::FORMAT_3, [], false], + [self::FORMAT_3, ['foo' => 'bar'], false], + [self::FORMAT_3, ['foo' => 'bar2'], true], + ]); + + return $encoder; + } } class NormalizationAwareEncoder implements EncoderInterface, NormalizationAwareInterface diff --git a/Tests/Mapping/Factory/CacheMetadataFactoryTest.php b/Tests/Mapping/Factory/CacheMetadataFactoryTest.php index 07ff9917f9d..47f2b96ee5d 100644 --- a/Tests/Mapping/Factory/CacheMetadataFactoryTest.php +++ b/Tests/Mapping/Factory/CacheMetadataFactoryTest.php @@ -16,7 +16,9 @@ use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Mapping\ClassMetadata; use Symfony\Component\Serializer\Mapping\Factory\CacheClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Tests\Fixtures\Dummy; /** @@ -59,8 +61,7 @@ public function testHasMetadataFor() public function testInvalidClassThrowsException() { $this->expectException(InvalidArgumentException::class); - $decorated = $this->createMock(ClassMetadataFactoryInterface::class); - $factory = new CacheClassMetadataFactory($decorated, new ArrayAdapter()); + $factory = new CacheClassMetadataFactory(new ClassMetadataFactory(new AttributeLoader()), new ArrayAdapter()); $factory->getMetadataFor('Not\Exist'); } diff --git a/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php b/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php index 683f445dfe2..388f3fed8e0 100644 --- a/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php +++ b/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php @@ -14,8 +14,10 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassMetadata; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\Mapping\Factory\CompiledClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedNameDummy; use Symfony\Component\Serializer\Tests\Fixtures\Dummy; @@ -26,8 +28,7 @@ final class CompiledClassMetadataFactoryTest extends TestCase { public function testItImplementsClassMetadataFactoryInterface() { - $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); - $compiledClassMetadataFactory = new CompiledClassMetadataFactory(__DIR__.'/../../Fixtures/serializer.class.metadata.php', $classMetadataFactory); + $compiledClassMetadataFactory = new CompiledClassMetadataFactory(__DIR__.'/../../Fixtures/serializer.class.metadata.php', new ClassMetadataFactory(new AttributeLoader())); $this->assertInstanceOf(ClassMetadataFactoryInterface::class, $compiledClassMetadataFactory); } @@ -37,8 +38,7 @@ public function testItThrowAnExceptionWhenCacheFileIsNotFound() $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('#File ".*/Fixtures/not-found-serializer.class.metadata.php" could not be found.#'); - $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); - new CompiledClassMetadataFactory(__DIR__.'/../../Fixtures/not-found-serializer.class.metadata.php', $classMetadataFactory); + new CompiledClassMetadataFactory(__DIR__.'/../../Fixtures/not-found-serializer.class.metadata.php', new ClassMetadataFactory(new AttributeLoader())); } public function testItThrowAnExceptionWhenMetadataIsNotOfTypeArray() @@ -46,8 +46,7 @@ public function testItThrowAnExceptionWhenMetadataIsNotOfTypeArray() $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Compiled metadata must be of the type array, object given.'); - $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); - new CompiledClassMetadataFactory(__DIR__.'/../../Fixtures/object-metadata.php', $classMetadataFactory); + new CompiledClassMetadataFactory(__DIR__.'/../../Fixtures/object-metadata.php', new ClassMetadataFactory(new AttributeLoader())); } /** @@ -91,8 +90,7 @@ public function testItDelegatesGetMetadataForCall() public function testItReturnsTheSameInstance() { - $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); - $compiledClassMetadataFactory = new CompiledClassMetadataFactory(__DIR__.'/../../Fixtures/serializer.class.metadata.php', $classMetadataFactory); + $compiledClassMetadataFactory = new CompiledClassMetadataFactory(__DIR__.'/../../Fixtures/serializer.class.metadata.php', new ClassMetadataFactory(new AttributeLoader())); $this->assertSame($compiledClassMetadataFactory->getMetadataFor(Dummy::class), $compiledClassMetadataFactory->getMetadataFor(Dummy::class)); } diff --git a/Tests/NameConverter/MetadataAwareNameConverterTest.php b/Tests/NameConverter/MetadataAwareNameConverterTest.php index c6ccd2601c9..28283bca1ba 100644 --- a/Tests/NameConverter/MetadataAwareNameConverterTest.php +++ b/Tests/NameConverter/MetadataAwareNameConverterTest.php @@ -16,7 +16,6 @@ use Symfony\Component\Serializer\Attribute\SerializedPath; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; -use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -30,8 +29,7 @@ final class MetadataAwareNameConverterTest extends TestCase { public function testInterface() { - $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); - $nameConverter = new MetadataAwareNameConverter($classMetadataFactory); + $nameConverter = new MetadataAwareNameConverter(new ClassMetadataFactory(new AttributeLoader())); $this->assertInstanceOf(NameConverterInterface::class, $nameConverter); } @@ -54,7 +52,7 @@ public function testNormalizeWithFallback(string|int $propertyName, string|int $ { $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); - $fallback = $this->createMock(NameConverterInterface::class); + $fallback = $this->createStub(NameConverterInterface::class); $fallback ->method('normalize') ->willReturnCallback(static fn ($propertyName) => strtoupper($propertyName)) @@ -84,7 +82,7 @@ public function testDenormalizeWithFallback(string|int $expected, string|int $pr { $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); - $fallback = $this->createMock(NameConverterInterface::class); + $fallback = $this->createStub(NameConverterInterface::class); $fallback ->method('denormalize') ->willReturnCallback(static fn ($propertyName) => strtolower($propertyName)) diff --git a/Tests/Normalizer/AbstractNormalizerTest.php b/Tests/Normalizer/AbstractNormalizerTest.php index 6f58a8a48a8..9fcdc0ba99b 100644 --- a/Tests/Normalizer/AbstractNormalizerTest.php +++ b/Tests/Normalizer/AbstractNormalizerTest.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\Serializer\Encoder\JsonEncoder; @@ -19,9 +18,7 @@ use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassMetadata; -use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; -use Symfony\Component\Serializer\Mapping\Loader\LoaderChain; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; @@ -46,12 +43,11 @@ class AbstractNormalizerTest extends TestCase { private AbstractNormalizerDummy $normalizer; - private MockObject&ClassMetadataFactoryInterface $classMetadata; + private ClassMetadataFactoryInterface $classMetadata; protected function setUp(): void { - $loader = $this->getMockBuilder(LoaderChain::class)->setConstructorArgs([[]])->getMock(); - $this->classMetadata = $this->getMockBuilder(ClassMetadataFactory::class)->setConstructorArgs([$loader])->getMock(); + $this->classMetadata = $this->createStub(ClassMetadataFactoryInterface::class); $this->normalizer = new AbstractNormalizerDummy($this->classMetadata); } diff --git a/Tests/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index c838ddac085..894b1fe5e04 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -422,7 +422,7 @@ public function testDenormalizeCollectionDecodedFromXmlWithTwoChildren() private function getDenormalizerForDummyCollection() { - $extractor = $this->createMock(PhpDocExtractor::class); + $extractor = $this->createStub(PhpDocExtractor::class); $extractor->method('getTypes') ->willReturn( [new Type('array', false, null, true, new Type('int'), new Type('object', false, DummyChild::class))], @@ -477,7 +477,7 @@ public function testDenormalizeNotSerializableObjectToPopulate() private function getDenormalizerForStringCollection() { - $extractor = $this->createMock(PhpDocExtractor::class); + $extractor = $this->createStub(PhpDocExtractor::class); $extractor->method('getTypes') ->willReturn( [new Type('array', false, null, true, new Type('int'), new Type('string'))], @@ -664,7 +664,7 @@ public function testDenormalizeBasicTypePropertiesFromXml() private function getDenormalizerForObjectWithBasicProperties() { - $extractor = $this->createMock(PhpDocExtractor::class); + $extractor = $this->createStub(PhpDocExtractor::class); $extractor->method('getTypes') ->willReturn( [new Type('bool')], diff --git a/Tests/Normalizer/ArrayDenormalizerTest.php b/Tests/Normalizer/ArrayDenormalizerTest.php index 8f7a75b1161..d4ccdc1eb33 100644 --- a/Tests/Normalizer/ArrayDenormalizerTest.php +++ b/Tests/Normalizer/ArrayDenormalizerTest.php @@ -11,23 +11,12 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Tests\Fixtures\UpcomingDenormalizerInterface as DenormalizerInterface; class ArrayDenormalizerTest extends TestCase { - private ArrayDenormalizer $denormalizer; - private MockObject&DenormalizerInterface $serializer; - - protected function setUp(): void - { - $this->serializer = $this->createMock(DenormalizerInterface::class); - $this->denormalizer = new ArrayDenormalizer(); - $this->denormalizer->setDenormalizer($this->serializer); - } - public function testDenormalize() { $series = [ @@ -35,7 +24,8 @@ public function testDenormalize() [[['foo' => 'three', 'bar' => 'four']], new ArrayDummy('three', 'four')], ]; - $this->serializer->expects($this->exactly(2)) + $nestedDenormalizer = $this->createMock(DenormalizerInterface::class); + $nestedDenormalizer->expects($this->exactly(2)) ->method('denormalize') ->willReturnCallback(function ($data) use (&$series) { [$expectedArgs, $return] = array_shift($series); @@ -45,7 +35,9 @@ public function testDenormalize() }) ; - $result = $this->denormalizer->denormalize( + $denormalizer = new ArrayDenormalizer(); + $denormalizer->setDenormalizer($nestedDenormalizer); + $result = $denormalizer->denormalize( [ ['foo' => 'one', 'bar' => 'two'], ['foo' => 'three', 'bar' => 'four'], @@ -64,13 +56,16 @@ public function testDenormalize() public function testSupportsValidArray() { - $this->serializer->expects($this->once()) + $nestedDenormalizer = $this->createMock(DenormalizerInterface::class); + $nestedDenormalizer->expects($this->once()) ->method('supportsDenormalization') ->with($this->anything(), ArrayDummy::class, 'json', ['con' => 'text']) ->willReturn(true); + $denormalizer = new ArrayDenormalizer(); + $denormalizer->setDenormalizer($nestedDenormalizer); $this->assertTrue( - $this->denormalizer->supportsDenormalization( + $denormalizer->supportsDenormalization( [ ['foo' => 'one', 'bar' => 'two'], ['foo' => 'three', 'bar' => 'four'], @@ -84,12 +79,15 @@ public function testSupportsValidArray() public function testSupportsInvalidArray() { - $this->serializer->expects($this->any()) + $nestedDenormalizer = $this->createStub(DenormalizerInterface::class); + $nestedDenormalizer ->method('supportsDenormalization') ->willReturn(false); + $denormalizer = new ArrayDenormalizer(); + $denormalizer->setDenormalizer($nestedDenormalizer); $this->assertFalse( - $this->denormalizer->supportsDenormalization( + $denormalizer->supportsDenormalization( [ ['foo' => 'one', 'bar' => 'two'], ['foo' => 'three', 'bar' => 'four'], @@ -101,8 +99,11 @@ public function testSupportsInvalidArray() public function testSupportsNoArray() { + $denormalizer = new ArrayDenormalizer(); + $denormalizer->setDenormalizer($this->createStub(DenormalizerInterface::class)); + $this->assertFalse( - $this->denormalizer->supportsDenormalization( + $denormalizer->supportsDenormalization( ['foo' => 'one', 'bar' => 'two'], ArrayDummy::class ) diff --git a/Tests/Normalizer/FormErrorNormalizerTest.php b/Tests/Normalizer/FormErrorNormalizerTest.php index bc18125cf93..0592be1815c 100644 --- a/Tests/Normalizer/FormErrorNormalizerTest.php +++ b/Tests/Normalizer/FormErrorNormalizerTest.php @@ -26,7 +26,7 @@ protected function setUp(): void { $this->normalizer = new FormErrorNormalizer(); - $this->form = $this->createMock(FormInterface::class); + $this->form = $this->createStub(FormInterface::class); $this->form->method('isSubmitted')->willReturn(true); $this->form->method('all')->willReturn([]); @@ -45,7 +45,7 @@ public function testSupportsNormalizationWithWrongClass() public function testSupportsNormalizationWithNotSubmittedForm() { - $form = $this->createMock(FormInterface::class); + $form = $this->createStub(FormInterface::class); $this->assertFalse($this->normalizer->supportsNormalization($form)); } @@ -77,7 +77,7 @@ public function testNormalize() public function testNormalizeWithChildren() { - $exptected = [ + $expected = [ 'code' => null, 'title' => 'Validation Failed', 'type' => 'https://symfony.com/errors/form', @@ -117,7 +117,7 @@ public function testNormalizeWithChildren() ], ]; - $form = clone $form1 = clone $form2 = clone $form3 = $this->createMock(FormInterface::class); + $form = clone $form1 = clone $form2 = clone $form3 = $this->createStub(FormInterface::class); $form1->method('getErrors') ->willReturn(new FormErrorIterator($form1, [ @@ -142,7 +142,7 @@ public function testNormalizeWithChildren() $form2->method('all')->willReturn([$form3]); - $form = $this->createMock(FormInterface::class); + $form = $this->createStub(FormInterface::class); $form->method('isSubmitted')->willReturn(true); $form->method('all')->willReturn([$form1, $form2]); $form->method('getErrors') @@ -151,6 +151,6 @@ public function testNormalizeWithChildren() ]) ); - $this->assertEquals($exptected, $this->normalizer->normalize($form)); + $this->assertEquals($expected, $this->normalizer->normalize($form)); } } diff --git a/Tests/Normalizer/GetSetMethodNormalizerTest.php b/Tests/Normalizer/GetSetMethodNormalizerTest.php index 72a56de9d1a..afc2b1f8767 100644 --- a/Tests/Normalizer/GetSetMethodNormalizerTest.php +++ b/Tests/Normalizer/GetSetMethodNormalizerTest.php @@ -395,7 +395,7 @@ public function testUnableToNormalizeObjectAttribute() { $this->expectException(LogicException::class); $this->expectExceptionMessage('Cannot normalize attribute "object" because the injected serializer is not a normalizer'); - $this->normalizer->setSerializer($this->createMock(SerializerInterface::class)); + $this->normalizer->setSerializer($this->createStub(SerializerInterface::class)); $obj = new GetSetDummy(); $object = new \stdClass(); diff --git a/Tests/Normalizer/JsonSerializableNormalizerTest.php b/Tests/Normalizer/JsonSerializableNormalizerTest.php index 54a977f55ec..2ef45d90116 100644 --- a/Tests/Normalizer/JsonSerializableNormalizerTest.php +++ b/Tests/Normalizer/JsonSerializableNormalizerTest.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Exception\CircularReferenceException; use Symfony\Component\Serializer\Exception\InvalidArgumentException; @@ -30,30 +29,19 @@ class JsonSerializableNormalizerTest extends TestCase { use CircularReferenceTestTrait; - private JsonSerializableNormalizer $normalizer; - private MockObject&JsonSerializerNormalizer $serializer; - - protected function setUp(): void - { - $this->createNormalizer(); - } - - private function createNormalizer(array $defaultContext = []) - { - $this->serializer = $this->createMock(JsonSerializerNormalizer::class); - $this->normalizer = new JsonSerializableNormalizer(null, null, $defaultContext); - $this->normalizer->setSerializer($this->serializer); - } - public function testSupportNormalization() { - $this->assertTrue($this->normalizer->supportsNormalization(new JsonSerializableDummy())); - $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + $normalizer = new JsonSerializableNormalizer(); + $normalizer->setSerializer($this->createStub(JsonSerializerNormalizer::class)); + + $this->assertTrue($normalizer->supportsNormalization(new JsonSerializableDummy())); + $this->assertFalse($normalizer->supportsNormalization(new \stdClass())); } public function testNormalize() { - $this->serializer + $serializer = $this->createMock(JsonSerializerNormalizer::class); + $serializer ->expects($this->once()) ->method('normalize') ->willReturnCallback(function ($data) { @@ -62,26 +50,30 @@ public function testNormalize() return 'string_object'; }) ; + $normalizer = new JsonSerializableNormalizer(); + $normalizer->setSerializer($serializer); - $this->assertEquals('string_object', $this->normalizer->normalize(new JsonSerializableDummy())); + $this->assertEquals('string_object', $normalizer->normalize(new JsonSerializableDummy())); } public function testCircularNormalize() { $this->expectException(CircularReferenceException::class); - $this->createNormalizer([JsonSerializableNormalizer::CIRCULAR_REFERENCE_LIMIT => 1]); + $normalizer = new JsonSerializableNormalizer(null, null, [JsonSerializableNormalizer::CIRCULAR_REFERENCE_LIMIT => 1]); - $this->serializer + $serializer = $this->createMock(JsonSerializerNormalizer::class); + $serializer ->expects($this->once()) ->method('normalize') - ->willReturnCallback(function ($data, $format, $context) { - $this->normalizer->normalize($data['qux'], $format, $context); + ->willReturnCallback(function ($data, $format, $context) use ($normalizer) { + $normalizer->normalize($data['qux'], $format, $context); return 'string_object'; }) ; + $normalizer->setSerializer($serializer); - $this->assertEquals('string_object', $this->normalizer->normalize(new JsonSerializableDummy())); + $this->assertEquals('string_object', $normalizer->normalize(new JsonSerializableDummy())); } protected function getNormalizerForCircularReference(array $defaultContext): JsonSerializableNormalizer @@ -99,9 +91,12 @@ protected function getSelfReferencingModel() public function testInvalidDataThrowException() { + $normalizer = new JsonSerializableNormalizer(); + $normalizer->setSerializer($this->createStub(JsonSerializerNormalizer::class)); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The object must implement "JsonSerializable".'); - $this->normalizer->normalize(new \stdClass()); + $normalizer->normalize(new \stdClass()); } } diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index 99cbc6f1e5b..eb508a49a0b 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -295,7 +295,7 @@ public function testConstructorWithObjectDenormalize() public function testConstructorWithObjectDenormalizeUsingPropertyInfoExtractor() { - $serializer = $this->createMock(ObjectSerializerNormalizer::class); + $serializer = $this->createStub(ObjectSerializerNormalizer::class); $normalizer = new ObjectNormalizer(null, null, null, null, null, null, [], new PropertyInfoExtractor()); $normalizer->setSerializer($serializer); @@ -684,7 +684,7 @@ public function testUnableToNormalizeObjectAttribute() { $this->expectException(LogicException::class); $this->expectExceptionMessage('Cannot normalize attribute "object" because the injected serializer is not a normalizer'); - $serializer = $this->createMock(SerializerInterface::class); + $serializer = $this->createStub(SerializerInterface::class); $this->normalizer->setSerializer($serializer); $obj = new ObjectDummy(); diff --git a/Tests/Normalizer/PropertyNormalizerTest.php b/Tests/Normalizer/PropertyNormalizerTest.php index 7711801ca16..6a54bedaf85 100644 --- a/Tests/Normalizer/PropertyNormalizerTest.php +++ b/Tests/Normalizer/PropertyNormalizerTest.php @@ -72,7 +72,7 @@ protected function setUp(): void private function createNormalizer(array $defaultContext = []): void { - $this->serializer = $this->createMock(SerializerInterface::class); + $this->serializer = $this->createStub(SerializerInterface::class); $this->normalizer = new PropertyNormalizer(null, null, null, null, null, $defaultContext); $this->normalizer->setSerializer($this->serializer); } @@ -437,7 +437,7 @@ public function testUnableToNormalizeObjectAttribute() { $this->expectException(LogicException::class); $this->expectExceptionMessage('Cannot normalize attribute "bar" because the injected serializer is not a normalizer'); - $serializer = $this->createMock(SerializerInterface::class); + $serializer = $this->createStub(SerializerInterface::class); $this->normalizer->setSerializer($serializer); $obj = new PropertyDummy(); diff --git a/Tests/Normalizer/TranslatableNormalizerTest.php b/Tests/Normalizer/TranslatableNormalizerTest.php index 3499521b65c..ced0e663929 100644 --- a/Tests/Normalizer/TranslatableNormalizerTest.php +++ b/Tests/Normalizer/TranslatableNormalizerTest.php @@ -15,6 +15,7 @@ use Symfony\Component\Serializer\Normalizer\TranslatableNormalizer; use Symfony\Contracts\Translation\TranslatableInterface; use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\Contracts\Translation\TranslatorTrait; class TranslatableNormalizerTest extends TestCase { @@ -22,7 +23,7 @@ class TranslatableNormalizerTest extends TestCase protected function setUp(): void { - $this->normalizer = new TranslatableNormalizer($this->createMock(TranslatorInterface::class)); + $this->normalizer = new TranslatableNormalizer(new IdentityTranslator()); } public function testSupportsNormalization() @@ -43,7 +44,7 @@ public function testNormalize() public function testNormalizeWithNormalizationLocalePassedInConstructor() { $normalizer = new TranslatableNormalizer( - $this->createMock(TranslatorInterface::class), + new IdentityTranslator(), ['translatable_normalization_locale' => 'es'], ); $message = new TestMessage(); @@ -61,3 +62,8 @@ public function trans(TranslatorInterface $translator, ?string $locale = null): return 'key_'.($locale ?? 'null'); } } + +class IdentityTranslator implements TranslatorInterface +{ + use TranslatorTrait; +} diff --git a/Tests/Normalizer/UnwrappinDenormalizerTest.php b/Tests/Normalizer/UnwrappingDenormalizerTest.php similarity index 62% rename from Tests/Normalizer/UnwrappinDenormalizerTest.php rename to Tests/Normalizer/UnwrappingDenormalizerTest.php index 59ddd6da65a..80d4a580735 100644 --- a/Tests/Normalizer/UnwrappinDenormalizerTest.php +++ b/Tests/Normalizer/UnwrappingDenormalizerTest.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; use Symfony\Component\Serializer\Serializer; @@ -20,23 +19,16 @@ /** * @author Eduard Bulava */ -class UnwrappinDenormalizerTest extends TestCase +class UnwrappingDenormalizerTest extends TestCase { - private UnwrappingDenormalizer $denormalizer; - private MockObject&Serializer $serializer; - - protected function setUp(): void - { - $this->serializer = $this->createMock(Serializer::class); - $this->denormalizer = new UnwrappingDenormalizer(); - $this->denormalizer->setSerializer($this->serializer); - } - public function testSupportsNormalization() { - $this->assertTrue($this->denormalizer->supportsDenormalization([], 'stdClass', 'any', [UnwrappingDenormalizer::UNWRAP_PATH => '[baz][inner]'])); - $this->assertFalse($this->denormalizer->supportsDenormalization([], 'stdClass', 'any', [UnwrappingDenormalizer::UNWRAP_PATH => '[baz][inner]', 'unwrapped' => true])); - $this->assertFalse($this->denormalizer->supportsDenormalization([], 'stdClass', 'any', [])); + $denormalizer = new UnwrappingDenormalizer(); + $denormalizer->setSerializer($this->createStub(Serializer::class)); + + $this->assertTrue($denormalizer->supportsDenormalization([], 'stdClass', 'any', [UnwrappingDenormalizer::UNWRAP_PATH => '[baz][inner]'])); + $this->assertFalse($denormalizer->supportsDenormalization([], 'stdClass', 'any', [UnwrappingDenormalizer::UNWRAP_PATH => '[baz][inner]', 'unwrapped' => true])); + $this->assertFalse($denormalizer->supportsDenormalization([], 'stdClass', 'any', [])); } public function testDenormalize() @@ -46,12 +38,15 @@ public function testDenormalize() $expected->bar = 'bar'; $expected->setFoo('foo'); - $this->serializer->expects($this->exactly(1)) + $serializer = $this->createMock(Serializer::class); + $serializer->expects($this->exactly(1)) ->method('denormalize') ->with(['foo' => 'foo', 'bar' => 'bar', 'baz' => true]) ->willReturn($expected); + $denormalizer = new UnwrappingDenormalizer(); + $denormalizer->setSerializer($serializer); - $result = $this->denormalizer->denormalize( + $result = $denormalizer->denormalize( ['data' => ['foo' => 'foo', 'bar' => 'bar', 'baz' => true]], ObjectDummy::class, 'any', @@ -65,12 +60,15 @@ public function testDenormalize() public function testDenormalizeInvalidPath() { - $this->serializer->expects($this->exactly(1)) + $serializer = $this->createMock(Serializer::class); + $serializer->expects($this->exactly(1)) ->method('denormalize') ->with(null) ->willReturn(new ObjectDummy()); + $denormalizer = new UnwrappingDenormalizer(); + $denormalizer->setSerializer($serializer); - $obj = $this->denormalizer->denormalize( + $obj = $denormalizer->denormalize( ['data' => ['foo' => 'foo', 'bar' => 'bar', 'baz' => true]], ObjectDummy::class, 'any', diff --git a/Tests/SerializerTest.php b/Tests/SerializerTest.php index 952ffc6cb96..417a5c5ad45 100644 --- a/Tests/SerializerTest.php +++ b/Tests/SerializerTest.php @@ -102,7 +102,7 @@ public function testItThrowsExceptionOnInvalidEncoder() public function testNormalizeNoMatch() { $this->expectException(UnexpectedValueException::class); - $serializer = new Serializer([$this->createMock(CustomNormalizer::class)]); + $serializer = new Serializer([$this->createStub(NormalizerInterface::class)]); $serializer->normalize(new \stdClass(), 'xml'); } @@ -130,7 +130,7 @@ public function testNormalizeOnDenormalizer() public function testDenormalizeNoMatch() { $this->expectException(UnexpectedValueException::class); - $serializer = new Serializer([$this->createMock(CustomNormalizer::class)]); + $serializer = new Serializer([$this->createStub(NormalizerInterface::class)]); $serializer->denormalize('foo', 'stdClass'); } @@ -161,13 +161,13 @@ public function testCustomNormalizerCanNormalizeCollectionsAndScalar() public function testNormalizeWithSupportOnData() { - $normalizer1 = $this->createMock(NormalizerInterface::class); + $normalizer1 = $this->createStub(NormalizerInterface::class); $normalizer1->method('getSupportedTypes')->willReturn(['*' => false]); $normalizer1->method('supportsNormalization') ->willReturnCallback(fn ($data, $format) => isset($data->test)); $normalizer1->method('normalize')->willReturn('test1'); - $normalizer2 = $this->createMock(NormalizerInterface::class); + $normalizer2 = $this->createStub(NormalizerInterface::class); $normalizer2->method('getSupportedTypes')->willReturn(['*' => false]); $normalizer2->method('supportsNormalization') ->willReturn(true); @@ -184,13 +184,13 @@ public function testNormalizeWithSupportOnData() public function testDenormalizeWithSupportOnData() { - $denormalizer1 = $this->createMock(DenormalizerInterface::class); + $denormalizer1 = $this->createStub(DenormalizerInterface::class); $denormalizer1->method('getSupportedTypes')->willReturn(['*' => false]); $denormalizer1->method('supportsDenormalization') ->willReturnCallback(fn ($data, $type, $format) => isset($data['test1'])); $denormalizer1->method('denormalize')->willReturn('test1'); - $denormalizer2 = $this->createMock(DenormalizerInterface::class); + $denormalizer2 = $this->createStub(DenormalizerInterface::class); $denormalizer2->method('getSupportedTypes')->willReturn(['*' => false]); $denormalizer2->method('supportsDenormalization') ->willReturn(true); From 8e02b21b88a4978aad275f002f614ac9eb52469b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 20 Jan 2026 19:35:01 +0100 Subject: [PATCH 7/7] [Serializer] Fix is/has/can accessor naming regression while preserving collision detection Before PR #61097 (Symfony 6.4.26/7.3.5), methods like isPublished() would serialize using the base name "published" by default. PR #61097 fixed an issue where objects with both $isPublished property and isPublished() method couldn't round-trip properly. However, it caused a regression: ALL is/has/can accessors started using the prefixed form ("isPublished") when a matching property exists, even when there's no actual collision. This PR fixes the regression by: 1. Only using the full method name (e.g., "isPublished") when there's an actual collision - another property or accessor that would map to the same base name ("published") 2. Keeping the base name ("published") when no collision exists, matching pre-6.4.26 behavior for the common case 3. Extracting shared collision detection logic into AccessorCollisionResolverTrait to ensure consistent behavior between ObjectNormalizer and AttributeLoader --- .../Loader/AccessorCollisionResolverTrait.php | 101 +++++++++++ Mapping/Loader/AttributeLoader.php | 37 +--- Normalizer/ObjectNormalizer.php | 49 +---- Tests/Normalizer/ObjectNormalizerTest.php | 168 +++++++++++++++++- 4 files changed, 282 insertions(+), 73 deletions(-) create mode 100644 Mapping/Loader/AccessorCollisionResolverTrait.php diff --git a/Mapping/Loader/AccessorCollisionResolverTrait.php b/Mapping/Loader/AccessorCollisionResolverTrait.php new file mode 100644 index 00000000000..8bd7d97cc4b --- /dev/null +++ b/Mapping/Loader/AccessorCollisionResolverTrait.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Mapping\Loader; + +use Symfony\Component\Serializer\Attribute\Ignore; + +/** + * Provides methods to detect accessor name collisions during serialization. + * + * @internal + */ +trait AccessorCollisionResolverTrait +{ + private function getAttributeNameFromAccessor(\ReflectionClass $class, \ReflectionMethod $method, bool $andMutator): ?string + { + $methodName = $method->name; + + $i = match ($methodName[0]) { + 's' => $andMutator && str_starts_with($methodName, 'set') ? 3 : null, + 'g' => str_starts_with($methodName, 'get') ? 3 : null, + 'h' => str_starts_with($methodName, 'has') ? 3 : null, + 'c' => str_starts_with($methodName, 'can') ? 3 : null, + 'i' => str_starts_with($methodName, 'is') ? 2 : null, + default => null, + }; + + // ctype_lower check to find out if method looks like accessor but actually is not, e.g. hash, cancel + if (null === $i || ctype_lower($methodName[$i] ?? 'a') || $method->isStatic()) { + return null; + } + + if ('s' === $methodName[0] ? !$method->getNumberOfParameters() : ($method->getNumberOfRequiredParameters() || \in_array((string) $method->getReturnType(), ['void', 'never'], true))) { + return null; + } + + $attributeName = substr($methodName, $i); + + if (!$class->hasProperty($attributeName)) { + $attributeName = lcfirst($attributeName); + } + + return $attributeName; + } + + private function hasPropertyForAccessor(\ReflectionClass $class, string $propName): bool + { + do { + if ($class->hasProperty($propName)) { + return true; + } + } while ($class = $class->getParentClass()); + + return false; + } + + private function hasAttributeNameCollision(\ReflectionClass $class, string $attributeName, string $methodName): bool + { + if ($this->hasPropertyForAccessor($class, $attributeName)) { + return true; + } + + if ($class->hasMethod($attributeName)) { + $candidate = $class->getMethod($attributeName); + if ($candidate->getName() !== $methodName && $this->isReadableAccessorMethod($candidate)) { + return true; + } + } + + $ucAttributeName = ucfirst($attributeName); + foreach (['get', 'is', 'has', 'can'] as $prefix) { + $candidateName = $prefix.$ucAttributeName; + if ($candidateName === $methodName || !$class->hasMethod($candidateName)) { + continue; + } + + if ($this->isReadableAccessorMethod($class->getMethod($candidateName))) { + return true; + } + } + + return false; + } + + private function isReadableAccessorMethod(\ReflectionMethod $method): bool + { + return $method->isPublic() + && !$method->isStatic() + && !$method->getAttributes(Ignore::class) + && !$method->getNumberOfRequiredParameters() + && !\in_array((string) $method->getReturnType(), ['void', 'never'], true); + } +} diff --git a/Mapping/Loader/AttributeLoader.php b/Mapping/Loader/AttributeLoader.php index 2ee8e127563..f744d089f78 100644 --- a/Mapping/Loader/AttributeLoader.php +++ b/Mapping/Loader/AttributeLoader.php @@ -34,6 +34,8 @@ */ class AttributeLoader implements LoaderInterface { + use AccessorCollisionResolverTrait; + private const KNOWN_ATTRIBUTES = [ DiscriminatorMap::class, Groups::class, @@ -129,25 +131,13 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool continue; /* matches the BC behavior in `Symfony\Component\Serializer\Normalizer\ObjectNormalizer::extractAttributes` */ } - $accessorOrMutator = match ($name[0]) { - 's' => str_starts_with($name, 'set') && isset($name[$i = 3]) && $method->getNumberOfParameters(), - 'g' => str_starts_with($name, 'get') && isset($name[$i = 3]), - 'h' => str_starts_with($name, 'has') && isset($name[$i = 3]), - 'c' => str_starts_with($name, 'can') && isset($name[$i = 3]), - 'i' => str_starts_with($name, 'is') && isset($name[$i = 2]), - default => false, - } && ('s' === $name[0] || !$method->getNumberOfRequiredParameters() && !\in_array((string) $method->getReturnType(), ['void', 'never'], true)); - - $hasProperty = $this->hasProperty($method->getDeclaringClass(), $name); - if ($hasProperty || $accessorOrMutator && !ctype_lower($name[$i])) { - if ($hasProperty) { - $attributeName = $name; - } else { - $attributeName = substr($name, $i); + $attributeName = $this->getAttributeNameFromAccessor($reflectionClass, $method, true); + $accessorOrMutator = null !== $attributeName; + $hasProperty = $this->hasPropertyForAccessor($method->getDeclaringClass(), $name); - if (!$reflectionClass->hasProperty($attributeName)) { - $attributeName = lcfirst($attributeName); - } + if ($hasProperty || $accessorOrMutator) { + if (null === $attributeName || 's' !== $name[0] && $hasProperty && $this->hasAttributeNameCollision($reflectionClass, $attributeName, $name)) { + $attributeName = $name; } if (isset($attributesMetadata[$attributeName])) { @@ -276,17 +266,6 @@ private function isKnownAttribute(string $attributeName): bool return false; } - private function hasProperty(\ReflectionClass $class, string $propName): bool - { - do { - if ($class->hasProperty($propName)) { - return true; - } - } while ($class = $class->getParentClass()); - - return false; - } - /** * @return object[] */ diff --git a/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index 619588b435f..0ba223209f9 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -18,10 +18,11 @@ 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\Attribute\Ignore; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\Mapping\Loader\AccessorCollisionResolverTrait; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -33,6 +34,8 @@ */ class ObjectNormalizer extends AbstractObjectNormalizer { + use AccessorCollisionResolverTrait; + private static $reflectionCache = []; private static $isReadableCache = []; private static $isWritableCache = []; @@ -87,37 +90,10 @@ protected function extractAttributes(object $object, ?string $format = null, arr $reflClass = new \ReflectionClass($class); foreach ($reflClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflMethod) { - if ( - $reflMethod->getNumberOfRequiredParameters() - || $reflMethod->isStatic() - || $reflMethod->isConstructor() - || $reflMethod->isDestructor() - || \in_array((string) $reflMethod->getReturnType(), ['void', 'never'], true) - ) { - continue; - } - $name = $reflMethod->name; - $attributeName = null; - - // ctype_lower check to find out if method looks like accessor but actually is not, e.g. hash, cancel - if (match ($name[0]) { - 'g' => str_starts_with($name, 'get') && isset($name[$i = 3]), - 'h' => str_starts_with($name, 'has') && isset($name[$i = 3]), - 'c' => str_starts_with($name, 'can') && isset($name[$i = 3]), - 'i' => str_starts_with($name, 'is') && isset($name[$i = 2]), - default => false, - } && !ctype_lower($name[$i])) { - if ($this->hasProperty($reflMethod->getDeclaringClass(), $name)) { - $attributeName = $name; - } else { - $attributeName = substr($name, $i); - - if (!$reflClass->hasProperty($attributeName)) { - $attributeName = lcfirst($attributeName); - } - } - } elseif ($this->hasProperty($reflMethod->getDeclaringClass(), $name)) { + $attributeName = $this->getAttributeNameFromAccessor($reflClass, $reflMethod, false); + + if ($this->hasPropertyForAccessor($reflMethod->getDeclaringClass(), $name) && (null === $attributeName || $this->hasAttributeNameCollision($reflClass, $attributeName, $name))) { $attributeName = $name; } @@ -142,17 +118,6 @@ protected function extractAttributes(object $object, ?string $format = null, arr return array_keys($attributes); } - private function hasProperty(\ReflectionClass $class, string $propName): bool - { - do { - if ($class->hasProperty($propName)) { - return true; - } - } while ($class = $class->getParentClass()); - - return false; - } - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { $mapping = $this->classDiscriminatorResolver?->getMappingForMappedObject($object); diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index a53d973a19a..d4cae637219 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -1186,6 +1186,61 @@ public function testPrecedenceOfAccessorMethods() ], $normalizedSwappedHasserIsser); } + public function testIsserPrefersBaseNameWhenNoCollision() + { + $normalizer = new ObjectNormalizer(); + + $object = new ObjectWithIsPrefixedPropertyOnly(true); + + $this->assertSame(['published' => true], $normalizer->normalize($object)); + } + + public function testIsserKeepsPrefixWhenBaseNameCollides() + { + $normalizer = new ObjectNormalizer(); + + $object = new ObjectWithIsPrefixedPropertyAndPublishedGetter(true, 'live'); + + $this->assertEquals([ + 'published' => 'live', + 'isPublished' => true, + ], $normalizer->normalize($object)); + } + + public function testIsserKeepsPrefixWhenPublicPropertyCollidesWithoutGetter() + { + $normalizer = new ObjectNormalizer(); + + $object = new ObjectWithIsserAndPublicPropertyNoGetter(true, 'live'); + + // Both should appear: isPublished keeps prefix because $published property exists + $this->assertEquals([ + 'isPublished' => true, + 'published' => 'live', + ], $normalizer->normalize($object)); + } + + public function testIsserWithPublicPropertyCollision() + { + $normalizer = new ObjectNormalizer(); + + $object = new ObjectWithPublicPublishedPropertyAndIsser('live'); + + // The isser takes precedence over the public property - this documents existing behavior + $this->assertSame(['published' => true], $normalizer->normalize($object)); + } + + public function testIsserWithPrivatePropertyNoMethodNamedProperty() + { + $normalizer = new ObjectNormalizer(); + + $object = new ObjectWithPrivatePublishedAndIsser(true); + + // isPublished() should normalize to 'published', not 'isPublished' + // because there's no $isPublished property that would cause a collision + $this->assertSame(['published' => true], $normalizer->normalize($object)); + } + public function testDiscriminatorWithAllowExtraAttributesFalse() { // Discriminator type property should be allowed with allow_extra_attributes=false @@ -1258,10 +1313,25 @@ public function testNormalizeObjectWithGroupsAndIsPrefixedProperty() $object = new GroupDummyWithIsPrefixedProperty(); $normalizedWithoutGroups = $normalizer->normalize($object); - $this->assertArrayHasKey('isSomething', $normalizedWithoutGroups); + $this->assertArrayHasKey('something', $normalizedWithoutGroups); $normalizedWithGroups = $normalizer->normalize($object, null, [AbstractNormalizer::GROUPS => ['test']]); - $this->assertArrayHasKey('isSomething', $normalizedWithGroups); + $this->assertArrayHasKey('something', $normalizedWithGroups); + } + + public function testNormalizeObjectWithGroupsAndIsPrefixedPropertyWithCollision() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory); + $serializer = new Serializer([$normalizer]); + $normalizer->setSerializer($serializer); + + $object = new GroupDummyWithIsPrefixedPropertyAndPublishedGetter(); + + $normalizedWithGroups = $normalizer->normalize($object, null, [AbstractNormalizer::GROUPS => ['test']]); + + $this->assertArrayHasKey('isPublished', $normalizedWithGroups); + $this->assertArrayNotHasKey('published', $normalizedWithGroups); } public function testSkipVoidNeverReturnTypeAccessors() @@ -1743,6 +1813,100 @@ public function hasFoo() } } +class ObjectWithIsPrefixedPropertyOnly +{ + public function __construct( + private bool $isPublished, + ) { + } + + public function isPublished(): bool + { + return $this->isPublished; + } +} + +class ObjectWithIsPrefixedPropertyAndPublishedGetter +{ + public function __construct( + private bool $isPublished, + private string $published, + ) { + } + + public function getPublished(): string + { + return $this->published; + } + + public function isPublished(): bool + { + return $this->isPublished; + } +} + +class GroupDummyWithIsPrefixedPropertyAndPublishedGetter +{ + private bool $isPublished = true; + private string $published = 'live'; + + #[Groups(['test'])] + public function isPublished(): bool + { + return $this->isPublished; + } + + public function getPublished(): string + { + return $this->published; + } +} + +class ObjectWithPublicPublishedPropertyAndIsser +{ + public string $published; + + public function __construct(string $published) + { + $this->published = $published; + } + + public function isPublished(): bool + { + return '' !== $this->published; + } +} + +class ObjectWithPrivatePublishedAndIsser +{ + public function __construct( + private bool $published, + ) { + } + + public function isPublished(): bool + { + return $this->published; + } +} + +class ObjectWithIsserAndPublicPropertyNoGetter +{ + public string $published; + + public function __construct( + private bool $isPublished, + string $published, + ) { + $this->published = $published; + } + + public function isPublished(): bool + { + return $this->isPublished; + } +} + class ObjectWithMethodSameNameThanProperty { public function __construct(