Skip to content

Navigation Menu

Sign in
Appearance settings
Sign up
Appearance settings

Commit efe12db

Browse filesBrowse the repository at this point in the historyBrowse files
feature #59529 [PropertyInfo] Allow defining accessors and mutators via an attribute (HypeMC)
This PR was merged into the 8.2 branch. Discussion ---------- [PropertyInfo] Allow defining accessors and mutators via an attribute | Q | A | ------------- | --- | Branch? | 8.2 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT A continuation of #38515. `ReflectionExtractor` finds accessors and mutators by convention: it tries each configured prefix against the property name, and asks the inflector whether that name is singular to decide between a whole-collection setter and an element-wise adder. Code that does not follow the convention is invisible to it, and the only way out today is a custom extractor. `#[WithAccessors]` lets a property name its methods instead: ```php class Foo { #[WithAccessors(getter: 'giveProp', setter: 'receiveProp', adder: 'pushProp', remover: 'popProp')] private array $prop; public function giveProp(): array {} public function receiveProp(array $prop): void {} public function pushProp(string $prop): void {} public function popProp(string $prop): void {} } ``` All four arguments are optional, and at least one must be given. `adder` and `remover` go together: naming one without the other throws. A setter may sit next to an adder and remover, the same way `setTags()` can coexist with `addTag()`/`removeTag()` under the existing conventions. Naming a method that does not exist throws a `MappingException` when the metadata is read, rather than falling back to discovery and silently resolving something else. The attribute is read on the declaring class and inherited, so a private property annotated on a parent applies to its children. Named methods are used as given. The prefix gating, the `is`/`has`/`can` handling and the singular/plural guessing all belong to discovery and are skipped entirely, so results do not shift with the extractor's configured prefixes. When both a setter and an adder are named, the adder determines the type, which yields the element type rather than the bare collection. Documentation should cover: the four arguments and that at least one is required, the adder/remover pairing rule, that a setter and an adder can be declared together and what each is used for, the `MappingException` on a missing method, inheritance from a parent's property, and that a named method bypasses the naming conventions. Commits ------- 8459d37 [PropertyInfo] Allow defining accessors and mutators via an attribute
2 parents f0ead93 + 8459d37 commit efe12db
Copy full SHA for efe12db

14 files changed

+677-15Lines changed: 677 additions & 15 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file
+32Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\PropertyInfo\Attribute;
13+
14+
use Symfony\Component\PropertyInfo\Exception\LogicException;
15+
16+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
17+
final class WithAccessors
18+
{
19+
public function __construct(
20+
public readonly ?string $getter = null,
21+
public readonly ?string $setter = null,
22+
public readonly ?string $adder = null,
23+
public readonly ?string $remover = null,
24+
) {
25+
if (!($this->getter || $this->setter || $this->adder || $this->remover)) {
26+
throw new LogicException('At least one of "getter", "setter", "adder", or "remover" must be defined.');
27+
}
28+
if ($this->adder xor $this->remover) {
29+
throw new LogicException('Both "adder" and "remover" must be defined when either is set.');
30+
}
31+
}
32+
}
Collapse file

‎src/Symfony/Component/PropertyInfo/CHANGELOG.md‎

Copy file name to clipboardExpand all lines: src/Symfony/Component/PropertyInfo/CHANGELOG.md
+5Lines changed: 5 additions & 0 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
8.2
5+
---
6+
7+
* Allow defining accessors and mutators via a `#[WithAccessors]` attribute
8+
49
8.1
510
---
611

Collapse file
+16Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\PropertyInfo\Exception;
13+
14+
interface ExceptionInterface extends \Throwable
15+
{
16+
}
Collapse file
+16Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\PropertyInfo\Exception;
13+
14+
class LogicException extends \LogicException implements ExceptionInterface
15+
{
16+
}
Collapse file
+27Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\PropertyInfo\Exception;
13+
14+
class MappingException extends \RuntimeException implements ExceptionInterface
15+
{
16+
/**
17+
* @param list<string> $invalidMethods
18+
*/
19+
public function __construct(
20+
string $message,
21+
public readonly string $forClass,
22+
public readonly array $invalidMethods,
23+
?\Throwable $previous = null,
24+
) {
25+
parent::__construct($message, 0, $previous);
26+
}
27+
}
Collapse file

‎src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php‎

Copy file name to clipboardExpand all lines: src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php
+178-13Lines changed: 178 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Symfony\Component\PropertyInfo\Extractor;
1313

14+
use Symfony\Component\PropertyInfo\Attribute\WithAccessors;
15+
use Symfony\Component\PropertyInfo\Exception\MappingException;
1416
use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface;
1517
use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface;
1618
use Symfony\Component\PropertyInfo\PropertyListExtractorInterface;
@@ -85,6 +87,10 @@ class ReflectionExtractor implements PropertyListExtractorInterface, PropertyTyp
8587
private array $arrayMutatorPrefixesFirst;
8688
private array $arrayMutatorPrefixesLast;
8789
private TypeResolverInterface $typeResolver;
90+
/** @var array<string, WithAccessors|null> */
91+
private array $accessorsAttributes = [];
92+
/** @var array<string, array<string, string>> */
93+
private array $accessorMethodToPropertyMap = [];
8894

8995
/**
9096
* @param string[]|null $mutatorPrefixes
@@ -141,7 +147,7 @@ public function getProperties(string $class, array $context = []): ?array
141147
continue;
142148
}
143149

144-
$propertyName = $this->getPropertyName($reflectionMethod->name, $reflectionProperties);
150+
$propertyName = $this->getPropertyName($reflectionClass, $reflectionMethod->name, $reflectionProperties);
145151
if (!$propertyName || isset($properties[$propertyName])) {
146152
continue;
147153
}
@@ -162,6 +168,10 @@ public function getType(string $class, string $property, array $context = []): ?
162168
return null;
163169
}
164170

171+
if (null !== $accessors = $this->getAccessorsAttribute($refClass, $property)) {
172+
return $this->extractTypeFromAccessors($refClass, $class, $property, $accessors);
173+
}
174+
165175
[$mutatorReflection, $prefix] = $this->getMutatorMethod($refClass, $property);
166176

167177
if ($mutatorReflection) {
@@ -287,6 +297,10 @@ public function isWritable(string $class, string $property, array $context = [])
287297
return null;
288298
}
289299

300+
if (null !== $accessors = $this->getAccessorsAttribute($refClass, $property)) {
301+
return null !== $accessors->setter || null !== $accessors->adder;
302+
}
303+
290304
// First test with the camelized property name
291305
[$reflectionMethod] = $this->getMutatorMethod($refClass, $this->camelize($property));
292306
if (null !== $reflectionMethod) {
@@ -332,6 +346,12 @@ public function getReadInfo(string $class, string $property, array $context = []
332346
return null;
333347
}
334348

349+
if (null !== $methodName = $this->getAccessorsAttribute($reflClass, $property)?->getter) {
350+
$method = $reflClass->getMethod($methodName);
351+
352+
return new PropertyReadInfo(PropertyReadInfo::TYPE_METHOD, $methodName, $this->getReadVisibilityForMethod($method), $method->isStatic(), false);
353+
}
354+
335355
$allowGetterSetter = $context['enable_getter_setter_extraction'] ?? false;
336356
$magicMethods = $context['enable_magic_methods_extraction'] ?? $this->magicMethodsFlags;
337357
$allowMagicCall = (bool) ($magicMethods & self::ALLOW_MAGIC_CALL);
@@ -375,12 +395,34 @@ public function getWriteInfo(string $class, string $property, array $context = [
375395
return null;
376396
}
377397

398+
$allowAdderRemover = $context['enable_adder_remover_extraction'] ?? true;
399+
400+
$accessorsAttribute = $this->getAccessorsAttribute($reflClass, $property);
401+
$adderAccessName = $accessorsAttribute?->adder;
402+
$removerAccessName = $accessorsAttribute?->remover;
403+
404+
if ($allowAdderRemover && null !== $adderAccessName && null !== $removerAccessName) {
405+
$adderMethod = $reflClass->getMethod($adderAccessName);
406+
$removerMethod = $reflClass->getMethod($removerAccessName);
407+
408+
$mutator = new PropertyWriteInfo(PropertyWriteInfo::TYPE_ADDER_AND_REMOVER);
409+
$mutator->setAdderInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $adderAccessName, $this->getWriteVisibilityForMethod($adderMethod), $adderMethod->isStatic()));
410+
$mutator->setRemoverInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $removerAccessName, $this->getWriteVisibilityForMethod($removerMethod), $removerMethod->isStatic()));
411+
412+
return $mutator;
413+
}
414+
415+
if (null !== $methodName = $accessorsAttribute?->setter) {
416+
$method = $reflClass->getMethod($methodName);
417+
418+
return new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $methodName, $this->getWriteVisibilityForMethod($method), $method->isStatic());
419+
}
420+
378421
$allowGetterSetter = $context['enable_getter_setter_extraction'] ?? false;
379422
$magicMethods = $context['enable_magic_methods_extraction'] ?? $this->magicMethodsFlags;
380423
$allowMagicCall = (bool) ($magicMethods & self::ALLOW_MAGIC_CALL);
381424
$allowMagicSet = (bool) ($magicMethods & self::ALLOW_MAGIC_SET);
382425
$allowConstruct = $context['enable_constructor_extraction'] ?? $this->enableConstructorExtraction;
383-
$allowAdderRemover = $context['enable_adder_remover_extraction'] ?? true;
384426

385427
$constructor = $reflClass->getConstructor();
386428
$errors = [];
@@ -396,19 +438,21 @@ public function getWriteInfo(string $class, string $property, array $context = [
396438
$camelized = $this->camelize($property);
397439
$nonCamelized = ucfirst($property);
398440

399-
[$adderAccessName, $removerAccessName, $adderAndRemoverErrors] = $this->findAdderAndRemover($reflClass, $camelized);
400-
if ($allowAdderRemover && null !== $adderAccessName && null !== $removerAccessName) {
401-
$adderMethod = $reflClass->getMethod($adderAccessName);
402-
$removerMethod = $reflClass->getMethod($removerAccessName);
441+
if (null === $adderAccessName || null === $removerAccessName) {
442+
[$adderAccessName, $removerAccessName, $adderAndRemoverErrors] = $this->findAdderAndRemover($reflClass, $camelized);
443+
if ($allowAdderRemover && null !== $adderAccessName && null !== $removerAccessName) {
444+
$adderMethod = $reflClass->getMethod($adderAccessName);
445+
$removerMethod = $reflClass->getMethod($removerAccessName);
403446

404-
$mutator = new PropertyWriteInfo(PropertyWriteInfo::TYPE_ADDER_AND_REMOVER);
405-
$mutator->setAdderInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $adderAccessName, $this->getWriteVisibilityForMethod($adderMethod), $adderMethod->isStatic()));
406-
$mutator->setRemoverInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $removerAccessName, $this->getWriteVisibilityForMethod($removerMethod), $removerMethod->isStatic()));
447+
$mutator = new PropertyWriteInfo(PropertyWriteInfo::TYPE_ADDER_AND_REMOVER);
448+
$mutator->setAdderInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $adderAccessName, $this->getWriteVisibilityForMethod($adderMethod), $adderMethod->isStatic()));
449+
$mutator->setRemoverInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $removerAccessName, $this->getWriteVisibilityForMethod($removerMethod), $removerMethod->isStatic()));
407450

408-
return $mutator;
409-
}
451+
return $mutator;
452+
}
410453

411-
$errors[] = $adderAndRemoverErrors;
454+
$errors[] = $adderAndRemoverErrors;
455+
}
412456

413457
foreach ($this->mutatorPrefixes as $mutatorPrefix) {
414458
$methodName = $mutatorPrefix.$camelized;
@@ -648,8 +692,12 @@ private function getMutatorMethod(\ReflectionClass $refClass, string $property):
648692
return null;
649693
}
650694

651-
private function getPropertyName(string $methodName, array $reflectionProperties): ?string
695+
private function getPropertyName(\ReflectionClass $refClass, string $methodName, array $reflectionProperties): ?string
652696
{
697+
if (null !== $propertyName = $this->getAccessorMethodFromAttribute($refClass, $reflectionProperties, $methodName)) {
698+
return $propertyName;
699+
}
700+
653701
$pattern = implode('|', array_merge($this->accessorPrefixes, $this->mutatorPrefixes));
654702

655703
if ('' !== $pattern && preg_match('/^('.$pattern.')(.+)$/i', $methodName, $matches)) {
@@ -852,4 +900,121 @@ private function getWriteVisibilityForMethod(\ReflectionMethod $reflectionMethod
852900

853901
return PropertyWriteInfo::VISIBILITY_PUBLIC;
854902
}
903+
904+
/**
905+
* Resolves the type from the methods named by the attribute.
906+
*
907+
* A named method is used as is: the prefix gating and the singular/plural guessing that drive
908+
* discovery must not second-guess a choice the application made explicit.
909+
*/
910+
private function extractTypeFromAccessors(\ReflectionClass $refClass, string $class, string $property, WithAccessors $accessors): ?Type
911+
{
912+
if (null !== $accessors->adder) {
913+
try {
914+
$type = $this->typeResolver->resolve($refClass->getMethod($accessors->adder)->getParameters()[0]);
915+
916+
if (!$type instanceof CollectionType) {
917+
$type = $this->isNullableProperty($class, $property) ? Type::nullable(Type::list($type)) : Type::list($type);
918+
}
919+
920+
return $type;
921+
} catch (UnsupportedException) {
922+
}
923+
}
924+
925+
if (null !== $accessors->setter) {
926+
try {
927+
return $this->typeResolver->resolve($refClass->getMethod($accessors->setter)->getParameters()[0]);
928+
} catch (UnsupportedException) {
929+
}
930+
}
931+
932+
if (null !== $accessors->getter) {
933+
try {
934+
return $this->typeResolver->resolve($refClass->getMethod($accessors->getter));
935+
} catch (UnsupportedException) {
936+
}
937+
}
938+
939+
try {
940+
return $this->typeResolver->resolve($refClass->getProperty($property));
941+
} catch (\ReflectionException|UnsupportedException) {
942+
}
943+
944+
return null;
945+
}
946+
947+
private function getAccessorsAttribute(\ReflectionClass $refClass, string $property): ?WithAccessors
948+
{
949+
$propertyHash = $refClass->name.'::'.$property;
950+
951+
if (\array_key_exists($propertyHash, $this->accessorsAttributes)) {
952+
return $this->accessorsAttributes[$propertyHash];
953+
}
954+
955+
if (!$refClass->hasProperty($property)) {
956+
if ($parentClass = $refClass->getParentClass()) {
957+
return $this->accessorsAttributes[$propertyHash] = $this->getAccessorsAttribute($parentClass, $property);
958+
}
959+
960+
return $this->accessorsAttributes[$propertyHash] = null;
961+
}
962+
963+
$refProperty = $refClass->getProperty($property);
964+
965+
/** @var \ReflectionAttribute<WithAccessors> $refAttribute */
966+
if (null === $refAttribute = $refProperty->getAttributes(WithAccessors::class)[0] ?? null) {
967+
return $this->accessorsAttributes[$propertyHash] = null;
968+
}
969+
970+
$accessorsAttribute = $refAttribute->newInstance();
971+
972+
$invalidAccessors = [];
973+
foreach ([$accessorsAttribute->getter, $accessorsAttribute->setter, $accessorsAttribute->adder, $accessorsAttribute->remover] as $accessor) {
974+
if (null !== $accessor && !$refClass->hasMethod($accessor)) {
975+
$invalidAccessors[] = $accessor;
976+
}
977+
}
978+
979+
if ($invalidAccessors) {
980+
throw new MappingException(\sprintf('Invalid #[WithAccessors] mapping on property "%s" of class "%s". The following methods are missing: "%s".', $refProperty->name, $refClass->name, implode('", "', $invalidAccessors)), $refClass->name, $invalidAccessors);
981+
}
982+
983+
return $this->accessorsAttributes[$propertyHash] = $accessorsAttribute;
984+
}
985+
986+
/**
987+
* @param \ReflectionProperty[] $reflectionProperties
988+
*/
989+
private function getAccessorMethodFromAttribute(\ReflectionClass $refClass, array $reflectionProperties, string $method): ?string
990+
{
991+
$className = $refClass->name;
992+
993+
if (!\array_key_exists($className, $this->accessorMethodToPropertyMap)) {
994+
$map = [];
995+
foreach ($reflectionProperties as $refProperty) {
996+
if (null === $accessorsAttribute = $this->getAccessorsAttribute($refClass, $refProperty->name)) {
997+
continue;
998+
}
999+
1000+
foreach ([$accessorsAttribute->getter, $accessorsAttribute->setter, $accessorsAttribute->adder, $accessorsAttribute->remover] as $accessor) {
1001+
if (null !== $accessor) {
1002+
$map[$accessor] = $refProperty->name;
1003+
}
1004+
}
1005+
}
1006+
1007+
$this->accessorMethodToPropertyMap[$className] = $map;
1008+
}
1009+
1010+
if (isset($this->accessorMethodToPropertyMap[$className][$method])) {
1011+
return $this->accessorMethodToPropertyMap[$className][$method];
1012+
}
1013+
1014+
if ($parentClass = $refClass->getParentClass()) {
1015+
return $this->getAccessorMethodFromAttribute($parentClass, $parentClass->getProperties(), $method);
1016+
}
1017+
1018+
return null;
1019+
}
8551020
}

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.