Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 8e02b21

Browse files
[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
1 parent 6556c8d commit 8e02b21

4 files changed

Lines changed: 282 additions & 73 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Serializer\Mapping\Loader;
13+
14+
use Symfony\Component\Serializer\Attribute\Ignore;
15+
16+
/**
17+
* Provides methods to detect accessor name collisions during serialization.
18+
*
19+
* @internal
20+
*/
21+
trait AccessorCollisionResolverTrait
22+
{
23+
private function getAttributeNameFromAccessor(\ReflectionClass $class, \ReflectionMethod $method, bool $andMutator): ?string
24+
{
25+
$methodName = $method->name;
26+
27+
$i = match ($methodName[0]) {
28+
's' => $andMutator && str_starts_with($methodName, 'set') ? 3 : null,
29+
'g' => str_starts_with($methodName, 'get') ? 3 : null,
30+
'h' => str_starts_with($methodName, 'has') ? 3 : null,
31+
'c' => str_starts_with($methodName, 'can') ? 3 : null,
32+
'i' => str_starts_with($methodName, 'is') ? 2 : null,
33+
default => null,
34+
};
35+
36+
// ctype_lower check to find out if method looks like accessor but actually is not, e.g. hash, cancel
37+
if (null === $i || ctype_lower($methodName[$i] ?? 'a') || $method->isStatic()) {
38+
return null;
39+
}
40+
41+
if ('s' === $methodName[0] ? !$method->getNumberOfParameters() : ($method->getNumberOfRequiredParameters() || \in_array((string) $method->getReturnType(), ['void', 'never'], true))) {
42+
return null;
43+
}
44+
45+
$attributeName = substr($methodName, $i);
46+
47+
if (!$class->hasProperty($attributeName)) {
48+
$attributeName = lcfirst($attributeName);
49+
}
50+
51+
return $attributeName;
52+
}
53+
54+
private function hasPropertyForAccessor(\ReflectionClass $class, string $propName): bool
55+
{
56+
do {
57+
if ($class->hasProperty($propName)) {
58+
return true;
59+
}
60+
} while ($class = $class->getParentClass());
61+
62+
return false;
63+
}
64+
65+
private function hasAttributeNameCollision(\ReflectionClass $class, string $attributeName, string $methodName): bool
66+
{
67+
if ($this->hasPropertyForAccessor($class, $attributeName)) {
68+
return true;
69+
}
70+
71+
if ($class->hasMethod($attributeName)) {
72+
$candidate = $class->getMethod($attributeName);
73+
if ($candidate->getName() !== $methodName && $this->isReadableAccessorMethod($candidate)) {
74+
return true;
75+
}
76+
}
77+
78+
$ucAttributeName = ucfirst($attributeName);
79+
foreach (['get', 'is', 'has', 'can'] as $prefix) {
80+
$candidateName = $prefix.$ucAttributeName;
81+
if ($candidateName === $methodName || !$class->hasMethod($candidateName)) {
82+
continue;
83+
}
84+
85+
if ($this->isReadableAccessorMethod($class->getMethod($candidateName))) {
86+
return true;
87+
}
88+
}
89+
90+
return false;
91+
}
92+
93+
private function isReadableAccessorMethod(\ReflectionMethod $method): bool
94+
{
95+
return $method->isPublic()
96+
&& !$method->isStatic()
97+
&& !$method->getAttributes(Ignore::class)
98+
&& !$method->getNumberOfRequiredParameters()
99+
&& !\in_array((string) $method->getReturnType(), ['void', 'never'], true);
100+
}
101+
}

Mapping/Loader/AttributeLoader.php

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
*/
3535
class AttributeLoader implements LoaderInterface
3636
{
37+
use AccessorCollisionResolverTrait;
38+
3739
private const KNOWN_ATTRIBUTES = [
3840
DiscriminatorMap::class,
3941
Groups::class,
@@ -129,25 +131,13 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool
129131
continue; /* matches the BC behavior in `Symfony\Component\Serializer\Normalizer\ObjectNormalizer::extractAttributes` */
130132
}
131133

132-
$accessorOrMutator = match ($name[0]) {
133-
's' => str_starts_with($name, 'set') && isset($name[$i = 3]) && $method->getNumberOfParameters(),
134-
'g' => str_starts_with($name, 'get') && isset($name[$i = 3]),
135-
'h' => str_starts_with($name, 'has') && isset($name[$i = 3]),
136-
'c' => str_starts_with($name, 'can') && isset($name[$i = 3]),
137-
'i' => str_starts_with($name, 'is') && isset($name[$i = 2]),
138-
default => false,
139-
} && ('s' === $name[0] || !$method->getNumberOfRequiredParameters() && !\in_array((string) $method->getReturnType(), ['void', 'never'], true));
140-
141-
$hasProperty = $this->hasProperty($method->getDeclaringClass(), $name);
142-
if ($hasProperty || $accessorOrMutator && !ctype_lower($name[$i])) {
143-
if ($hasProperty) {
144-
$attributeName = $name;
145-
} else {
146-
$attributeName = substr($name, $i);
134+
$attributeName = $this->getAttributeNameFromAccessor($reflectionClass, $method, true);
135+
$accessorOrMutator = null !== $attributeName;
136+
$hasProperty = $this->hasPropertyForAccessor($method->getDeclaringClass(), $name);
147137

148-
if (!$reflectionClass->hasProperty($attributeName)) {
149-
$attributeName = lcfirst($attributeName);
150-
}
138+
if ($hasProperty || $accessorOrMutator) {
139+
if (null === $attributeName || 's' !== $name[0] && $hasProperty && $this->hasAttributeNameCollision($reflectionClass, $attributeName, $name)) {
140+
$attributeName = $name;
151141
}
152142

153143
if (isset($attributesMetadata[$attributeName])) {
@@ -276,17 +266,6 @@ private function isKnownAttribute(string $attributeName): bool
276266
return false;
277267
}
278268

279-
private function hasProperty(\ReflectionClass $class, string $propName): bool
280-
{
281-
do {
282-
if ($class->hasProperty($propName)) {
283-
return true;
284-
}
285-
} while ($class = $class->getParentClass());
286-
287-
return false;
288-
}
289-
290269
/**
291270
* @return object[]
292271
*/

Normalizer/ObjectNormalizer.php

Lines changed: 7 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
1818
use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
1919
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
2020
use Symfony\Component\PropertyInfo\PropertyWriteInfo;
21-
use Symfony\Component\Serializer\Annotation\Ignore;
21+
use Symfony\Component\Serializer\Attribute\Ignore;
2222
use Symfony\Component\Serializer\Exception\LogicException;
2323
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface;
2424
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
25+
use Symfony\Component\Serializer\Mapping\Loader\AccessorCollisionResolverTrait;
2526
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
2627

2728
/**
@@ -33,6 +34,8 @@
3334
*/
3435
class ObjectNormalizer extends AbstractObjectNormalizer
3536
{
37+
use AccessorCollisionResolverTrait;
38+
3639
private static $reflectionCache = [];
3740
private static $isReadableCache = [];
3841
private static $isWritableCache = [];
@@ -87,37 +90,10 @@ protected function extractAttributes(object $object, ?string $format = null, arr
8790
$reflClass = new \ReflectionClass($class);
8891

8992
foreach ($reflClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflMethod) {
90-
if (
91-
$reflMethod->getNumberOfRequiredParameters()
92-
|| $reflMethod->isStatic()
93-
|| $reflMethod->isConstructor()
94-
|| $reflMethod->isDestructor()
95-
|| \in_array((string) $reflMethod->getReturnType(), ['void', 'never'], true)
96-
) {
97-
continue;
98-
}
99-
10093
$name = $reflMethod->name;
101-
$attributeName = null;
102-
103-
// ctype_lower check to find out if method looks like accessor but actually is not, e.g. hash, cancel
104-
if (match ($name[0]) {
105-
'g' => str_starts_with($name, 'get') && isset($name[$i = 3]),
106-
'h' => str_starts_with($name, 'has') && isset($name[$i = 3]),
107-
'c' => str_starts_with($name, 'can') && isset($name[$i = 3]),
108-
'i' => str_starts_with($name, 'is') && isset($name[$i = 2]),
109-
default => false,
110-
} && !ctype_lower($name[$i])) {
111-
if ($this->hasProperty($reflMethod->getDeclaringClass(), $name)) {
112-
$attributeName = $name;
113-
} else {
114-
$attributeName = substr($name, $i);
115-
116-
if (!$reflClass->hasProperty($attributeName)) {
117-
$attributeName = lcfirst($attributeName);
118-
}
119-
}
120-
} elseif ($this->hasProperty($reflMethod->getDeclaringClass(), $name)) {
94+
$attributeName = $this->getAttributeNameFromAccessor($reflClass, $reflMethod, false);
95+
96+
if ($this->hasPropertyForAccessor($reflMethod->getDeclaringClass(), $name) && (null === $attributeName || $this->hasAttributeNameCollision($reflClass, $attributeName, $name))) {
12197
$attributeName = $name;
12298
}
12399

@@ -142,17 +118,6 @@ protected function extractAttributes(object $object, ?string $format = null, arr
142118
return array_keys($attributes);
143119
}
144120

145-
private function hasProperty(\ReflectionClass $class, string $propName): bool
146-
{
147-
do {
148-
if ($class->hasProperty($propName)) {
149-
return true;
150-
}
151-
} while ($class = $class->getParentClass());
152-
153-
return false;
154-
}
155-
156121
protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed
157122
{
158123
$mapping = $this->classDiscriminatorResolver?->getMappingForMappedObject($object);

0 commit comments

Comments
 (0)