Skip to content

Commit bd1f4a1

Browse files
committed
[PropertyInfo][PropertyAccess] Feature: customize behavior for property hooks on read and write
1 parent dd061aa commit bd1f4a1

12 files changed

+195
-19
lines changed

src/Symfony/Component/PropertyAccess/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* Allow to customize behavior for property hooks on read and write
8+
49
7.0
510
---
611

src/Symfony/Component/PropertyAccess/PropertyAccessor.php

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ class PropertyAccessor implements PropertyAccessorInterface
4747
/** @var int Allow magic __call methods */
4848
public const MAGIC_CALL = ReflectionExtractor::ALLOW_MAGIC_CALL;
4949

50+
public const DO_NOT_BYPASS_HOOKS_ON_PROPERTY = 0;
51+
public const BYPASS_HOOKS_ON_PROPERTY_READ = 1 << 1;
52+
public const BYPASS_HOOKS_ON_PROPERTY_WRITE = 1 << 0;
53+
5054
public const DO_NOT_THROW = 0;
5155
public const THROW_ON_INVALID_INDEX = 1;
5256
public const THROW_ON_INVALID_PROPERTY_PATH = 2;
@@ -67,29 +71,35 @@ class PropertyAccessor implements PropertyAccessorInterface
6771
private PropertyWriteInfoExtractorInterface $writeInfoExtractor;
6872
private array $readPropertyCache = [];
6973
private array $writePropertyCache = [];
74+
private int $byPassPropertyHooks;
7075

7176
/**
7277
* Should not be used by application code. Use
7378
* {@link PropertyAccess::createPropertyAccessor()} instead.
7479
*
75-
* @param int $magicMethods A bitwise combination of the MAGIC_* constants
76-
* to specify the allowed magic methods (__get, __set, __call)
77-
* or self::DISALLOW_MAGIC_METHODS for none
78-
* @param int $throw A bitwise combination of the THROW_* constants
79-
* to specify when exceptions should be thrown
80+
* @param int $magicMethodsFlags A bitwise combination of the MAGIC_* constants
81+
* to specify the allowed magic methods (__get, __set, __call)
82+
* or self::DISALLOW_MAGIC_METHODS for none
83+
* @param int $throw A bitwise combination of the THROW_* constants
84+
* to specify when exceptions should be thrown
85+
* @param int $byPassPropertyHooks A bitwise combination of the BYPASS_HOOKS_* constants
86+
* to specify the hooks you want to bypass,
87+
* or self::DO_NOT_BYPASS_HOOKS_ON_PROPERTY for none
8088
*/
8189
public function __construct(
8290
private int $magicMethodsFlags = self::MAGIC_GET | self::MAGIC_SET,
8391
int $throw = self::THROW_ON_INVALID_PROPERTY_PATH,
8492
?CacheItemPoolInterface $cacheItemPool = null,
8593
?PropertyReadInfoExtractorInterface $readInfoExtractor = null,
8694
?PropertyWriteInfoExtractorInterface $writeInfoExtractor = null,
95+
int $byPassPropertyHooks = self::DO_NOT_BYPASS_HOOKS_ON_PROPERTY,
8796
) {
8897
$this->ignoreInvalidIndices = 0 === ($throw & self::THROW_ON_INVALID_INDEX);
8998
$this->cacheItemPool = $cacheItemPool instanceof NullAdapter ? null : $cacheItemPool; // Replace the NullAdapter by the null value
9099
$this->ignoreInvalidProperty = 0 === ($throw & self::THROW_ON_INVALID_PROPERTY_PATH);
91100
$this->readInfoExtractor = $readInfoExtractor ?? new ReflectionExtractor([], null, null, false);
92101
$this->writeInfoExtractor = $writeInfoExtractor ?? new ReflectionExtractor(['set'], null, null, false);
102+
$this->byPassPropertyHooks = \PHP_VERSION_ID >= 80400 ? $byPassPropertyHooks : self::DO_NOT_BYPASS_HOOKS_ON_PROPERTY;
93103
}
94104

95105
public function getValue(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): mixed
@@ -216,7 +226,7 @@ public function isReadable(object|array $objectOrArray, string|PropertyPathInter
216226
];
217227

218228
// handle stdClass with properties with a dot in the name
219-
if ($objectOrArray instanceof \stdClass && str_contains($propertyPath, '.') && property_exists($objectOrArray, $propertyPath)) {
229+
if ($objectOrArray instanceof \stdClass && str_contains($propertyPath, '.') && property_exists($objectOrArray, $propertyPath)) {
220230
$this->readProperty($zval, $propertyPath, $this->ignoreInvalidProperty);
221231
} else {
222232
$this->readPropertiesUntil($zval, $propertyPath, $propertyPath->getLength(), $this->ignoreInvalidIndices);
@@ -414,12 +424,21 @@ private function readProperty(array $zval, string $property, bool $ignoreInvalid
414424
throw $e;
415425
}
416426
} elseif (PropertyReadInfo::TYPE_PROPERTY === $type) {
417-
if (!isset($object->$name) && !\array_key_exists($name, (array) $object)) {
427+
$valueSet = false;
428+
$useBypass = $this->byPassPropertyHooks & self::BYPASS_HOOKS_ON_PROPERTY_READ && $access->hasHook() && !$access->isVirtual();
429+
$valueSeemsToBeNotSet = !isset($object->$name) && !\array_key_exists($name, (array) $object);
430+
if ($valueSeemsToBeNotSet || $useBypass) {
418431
try {
419432
$r = new \ReflectionProperty($class, $name);
433+
if ($valueSeemsToBeNotSet) {
434+
if ($r->isPublic() && !$r->hasType()) {
435+
throw new UninitializedPropertyException(\sprintf('The property "%s::$%s" is not initialized.', $class, $name));
436+
}
437+
}
420438

421-
if ($r->isPublic() && !$r->hasType()) {
422-
throw new UninitializedPropertyException(\sprintf('The property "%s::$%s" is not initialized.', $class, $name));
439+
if ($useBypass) {
440+
$result[self::VALUE] = $r->getRawValue($object);
441+
$valueSet = true;
423442
}
424443
} catch (\ReflectionException $e) {
425444
if (!$ignoreInvalidProperty) {
@@ -428,7 +447,9 @@ private function readProperty(array $zval, string $property, bool $ignoreInvalid
428447
}
429448
}
430449

431-
$result[self::VALUE] = $object->$name;
450+
if (!$valueSet) {
451+
$result[self::VALUE] = $object->$name;
452+
}
432453

433454
if (isset($zval[self::REF]) && $access->canBeReference()) {
434455
$result[self::REF] = &$object->$name;
@@ -531,7 +552,12 @@ private function writeProperty(array $zval, string $property, mixed $value, bool
531552
if (PropertyWriteInfo::TYPE_METHOD === $type) {
532553
$object->{$mutator->getName()}($value);
533554
} elseif (PropertyWriteInfo::TYPE_PROPERTY === $type) {
534-
$object->{$mutator->getName()} = $value;
555+
if ($this->byPassPropertyHooks & self::BYPASS_HOOKS_ON_PROPERTY_WRITE) {
556+
$r = new \ReflectionProperty($class, $mutator->getName());
557+
$r->setRawValue($object, $value);
558+
} else {
559+
$object->{$mutator->getName()} = $value;
560+
}
535561
} elseif (PropertyWriteInfo::TYPE_ADDER_AND_REMOVER === $type) {
536562
$this->writeCollection($zval, $property, $value, $mutator->getAdderInfo(), $mutator->getRemoverInfo());
537563
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Symfony\Component\PropertyAccess\Tests\Fixtures;
4+
5+
class TestClassHooks
6+
{
7+
public string $hookGetOnly = 'default' {
8+
get => $this->hookGetOnly . ' (hooked on get)';
9+
}
10+
11+
public string $hookSetOnly = 'default' {
12+
set(string $value) {
13+
$this->hookSetOnly = $value . ' (hooked on set)';
14+
}
15+
}
16+
17+
public string $hookBoth = 'default' {
18+
get => $this->hookBoth . ' (hooked on get)';
19+
set(string $value) {
20+
$this->hookBoth = $value . ' (hooked on set)';
21+
}
22+
}
23+
}

src/Symfony/Component/PropertyAccess/Tests/PropertyAccessorTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestAdderRemoverInvalidArgumentLength;
2626
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestAdderRemoverInvalidMethods;
2727
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestClass;
28+
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestClassHooks;
2829
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestClassIsWritable;
2930
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestClassMagicCall;
3031
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestClassMagicGet;
@@ -1029,6 +1030,45 @@ public function testIsReadableWithMissingPropertyAndLazyGhost()
10291030
$this->assertFalse($this->propertyAccessor->isReadable($lazyGhost, 'dummy'));
10301031
}
10311032

1033+
/**
1034+
* @requires PHP 8.4
1035+
*/
1036+
public function testBypassHookOnRead()
1037+
{
1038+
$instance = new TestClassHooks();
1039+
$propertyAccessor = new PropertyAccessor(byPassPropertyHooks: PropertyAccessor::BYPASS_HOOKS_ON_PROPERTY_READ);
1040+
$this->assertSame('default', $propertyAccessor->getValue($instance, 'hookGetOnly'));
1041+
$this->assertSame('default (hooked on get)', $this->propertyAccessor->getValue($instance, 'hookGetOnly'));
1042+
$this->assertSame('default', $propertyAccessor->getValue($instance, 'hookSetOnly'));
1043+
$this->assertSame('default', $this->propertyAccessor->getValue($instance, 'hookSetOnly'));
1044+
$this->assertSame('default', $propertyAccessor->getValue($instance, 'hookBoth'));
1045+
$this->assertSame('default (hooked on get)', $this->propertyAccessor->getValue($instance, 'hookBoth'));
1046+
}
1047+
1048+
/**
1049+
* @requires PHP 8.4
1050+
*/
1051+
public function testBypassHookOnWrite()
1052+
{
1053+
$instance = new TestClassHooks();
1054+
$propertyAccessor = new PropertyAccessor(byPassPropertyHooks: PropertyAccessor::BYPASS_HOOKS_ON_PROPERTY_WRITE);
1055+
$propertyAccessor->setValue($instance, 'hookGetOnly', 'edited');
1056+
$propertyAccessor->setValue($instance, 'hookSetOnly', 'edited');
1057+
$propertyAccessor->setValue($instance, 'hookBoth', 'edited');
1058+
1059+
$instance2 = new TestClassHooks();
1060+
$this->propertyAccessor->setValue($instance2, 'hookGetOnly', 'edited');
1061+
$this->propertyAccessor->setValue($instance2, 'hookSetOnly', 'edited');
1062+
$this->propertyAccessor->setValue($instance2, 'hookBoth', 'edited');
1063+
1064+
$this->assertSame('edited (hooked on get)', $propertyAccessor->getValue($instance, 'hookGetOnly'));
1065+
$this->assertSame('edited (hooked on get)', $this->propertyAccessor->getValue($instance2, 'hookGetOnly'));
1066+
$this->assertSame('edited', $propertyAccessor->getValue($instance, 'hookSetOnly'));
1067+
$this->assertSame('edited (hooked on set)', $this->propertyAccessor->getValue($instance2, 'hookSetOnly'));
1068+
$this->assertSame('edited (hooked on get)', $propertyAccessor->getValue($instance, 'hookBoth'));
1069+
$this->assertSame('edited (hooked on set) (hooked on get)', $this->propertyAccessor->getValue($instance2, 'hookBoth'));
1070+
}
1071+
10321072
private function createUninitializedObjectPropertyGhost(): UninitializedObjectProperty
10331073
{
10341074
if (!class_exists(ProxyHelper::class)) {

src/Symfony/Component/PropertyAccess/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
],
1818
"require": {
1919
"php": ">=8.2",
20-
"symfony/property-info": "^6.4|^7.0"
20+
"symfony/property-info": "^7.3"
2121
},
2222
"require-dev": {
2323
"symfony/cache": "^6.4|^7.0"

src/Symfony/Component/PropertyInfo/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
7.3
55
---
66

7+
* Gather data from property hooks in ReflectionExtractor
78
* Add support for `non-positive-int`, `non-negative-int` and `non-zero-int` PHPStan types to `PhpStanExtractor`
89

910
7.1

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -384,11 +384,11 @@ public function getReadInfo(string $class, string $property, array $context = []
384384
}
385385

386386
if ($allowMagicGet && $reflClass->hasMethod('__get') && (($r = $reflClass->getMethod('__get'))->getModifiers() & $this->methodReflectionFlags)) {
387-
return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, PropertyReadInfo::VISIBILITY_PUBLIC, false, $r->returnsReference());
387+
return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, PropertyReadInfo::VISIBILITY_PUBLIC, false, $r->returnsReference(), false, false);
388388
}
389389

390390
if ($hasProperty && (($r = $reflClass->getProperty($property))->getModifiers() & $this->propertyReflectionFlags)) {
391-
return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, $this->getReadVisiblityForProperty($r), $r->isStatic(), true);
391+
return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, $this->getReadVisiblityForProperty($r), $r->isStatic(), true, $this->propertyHasHook($r, 'get'), $this->propertyIsVirtual($r));
392392
}
393393

394394
if ($allowMagicCall && $reflClass->hasMethod('__call') && ($reflClass->getMethod('__call')->getModifiers() & $this->methodReflectionFlags)) {
@@ -472,7 +472,7 @@ public function getWriteInfo(string $class, string $property, array $context = [
472472
if ($reflClass->hasProperty($property) && ($reflClass->getProperty($property)->getModifiers() & $this->propertyReflectionFlags)) {
473473
$reflProperty = $reflClass->getProperty($property);
474474
if (!$reflProperty->isReadOnly()) {
475-
return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, $this->getWriteVisiblityForProperty($reflProperty), $reflProperty->isStatic());
475+
return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, $this->getWriteVisiblityForProperty($reflProperty), $reflProperty->isStatic(), $this->propertyHasHook($reflProperty, 'set'));
476476
}
477477

478478
$errors[] = [\sprintf('The property "%s" in class "%s" is a promoted readonly property.', $property, $reflClass->getName())];
@@ -482,7 +482,7 @@ public function getWriteInfo(string $class, string $property, array $context = [
482482
if ($allowMagicSet) {
483483
[$accessible, $methodAccessibleErrors] = $this->isMethodAccessible($reflClass, '__set', 2);
484484
if ($accessible) {
485-
return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, PropertyWriteInfo::VISIBILITY_PUBLIC, false);
485+
return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, PropertyWriteInfo::VISIBILITY_PUBLIC, false, false);
486486
}
487487

488488
$errors[] = $methodAccessibleErrors;
@@ -491,7 +491,7 @@ public function getWriteInfo(string $class, string $property, array $context = [
491491
if ($allowMagicCall) {
492492
[$accessible, $methodAccessibleErrors] = $this->isMethodAccessible($reflClass, '__call', 2);
493493
if ($accessible) {
494-
return new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, 'set'.$camelized, PropertyWriteInfo::VISIBILITY_PUBLIC, false);
494+
return new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, 'set'.$camelized, PropertyWriteInfo::VISIBILITY_PUBLIC, false, false);
495495
}
496496

497497
$errors[] = $methodAccessibleErrors;
@@ -885,6 +885,16 @@ private function isMethodAccessible(\ReflectionClass $class, string $methodName,
885885
return [false, $errors];
886886
}
887887

888+
private function propertyHasHook(\ReflectionProperty $property, string $hookType): bool
889+
{
890+
return \PHP_VERSION_ID >= 80400 && $property->hasHook(\PropertyHookType::from($hookType));
891+
}
892+
893+
private function propertyIsVirtual(\ReflectionProperty $property): bool
894+
{
895+
return \PHP_VERSION_ID >= 80400 && $property->isVirtual();
896+
}
897+
888898
/**
889899
* Camelizes a given string.
890900
*/

src/Symfony/Component/PropertyInfo/PropertyReadInfo.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public function __construct(
3333
private readonly string $visibility,
3434
private readonly bool $static,
3535
private readonly bool $byRef,
36+
private readonly ?bool $hasHook = null,
37+
private readonly ?bool $isVirtual = null,
3638
) {
3739
}
3840

@@ -69,4 +71,14 @@ public function canBeReference(): bool
6971
{
7072
return $this->byRef;
7173
}
74+
75+
public function hasHook(): ?bool
76+
{
77+
return $this->hasHook;
78+
}
79+
80+
public function isVirtual(): ?bool
81+
{
82+
return $this->isVirtual;
83+
}
7284
}

src/Symfony/Component/PropertyInfo/PropertyWriteInfo.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public function __construct(
3939
private readonly ?string $name = null,
4040
private readonly ?string $visibility = null,
4141
private readonly ?bool $static = null,
42+
private readonly ?bool $hasHook = null,
4243
) {
4344
}
4445

@@ -116,4 +117,9 @@ public function hasErrors(): bool
116117
{
117118
return (bool) \count($this->errors);
118119
}
120+
121+
public function hasHook(): ?bool
122+
{
123+
return $this->hasHook;
124+
}
119125
}

src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Symfony\Component\PropertyInfo\Tests\Fixtures\ConstructorDummy;
2121
use Symfony\Component\PropertyInfo\Tests\Fixtures\DefaultValue;
2222
use Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy;
23+
use Symfony\Component\PropertyInfo\Tests\Fixtures\HookedProperties;
2324
use Symfony\Component\PropertyInfo\Tests\Fixtures\NotInstantiable;
2425
use Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy;
2526
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php71Dummy;
@@ -754,8 +755,14 @@ public function testAsymmetricVisibilityAllowPrivateOnly()
754755
public function testVirtualProperties()
755756
{
756757
$this->assertTrue($this->extractor->isReadable(VirtualProperties::class, 'virtualNoSetHook'));
758+
$this->assertTrue($this->extractor->getReadInfo(VirtualProperties::class, 'virtualNoSetHook')->isVirtual());
759+
$this->assertTrue($this->extractor->getReadInfo(VirtualProperties::class, 'virtualNoSetHook')->hasHook());
757760
$this->assertTrue($this->extractor->isReadable(VirtualProperties::class, 'virtualSetHookOnly'));
761+
$this->assertTrue($this->extractor->getReadInfo(VirtualProperties::class, 'virtualSetHookOnly')->isVirtual());
762+
$this->assertFalse($this->extractor->getReadInfo(VirtualProperties::class, 'virtualSetHookOnly')->hasHook());
758763
$this->assertTrue($this->extractor->isReadable(VirtualProperties::class, 'virtualHook'));
764+
$this->assertTrue($this->extractor->getReadInfo(VirtualProperties::class, 'virtualHook')->isVirtual());
765+
$this->assertTrue($this->extractor->getReadInfo(VirtualProperties::class, 'virtualHook')->hasHook());
759766
$this->assertFalse($this->extractor->isWritable(VirtualProperties::class, 'virtualNoSetHook'));
760767
$this->assertTrue($this->extractor->isWritable(VirtualProperties::class, 'virtualSetHookOnly'));
761768
$this->assertTrue($this->extractor->isWritable(VirtualProperties::class, 'virtualHook'));
@@ -780,6 +787,22 @@ public function testAsymmetricVisibilityMutator(string $property, string $readVi
780787
$this->assertSame($writeVisibility, $writeMutator->getVisibility());
781788
}
782789

790+
/**
791+
* @requires PHP 8.4
792+
*/
793+
public function testHookedProperties()
794+
{
795+
$this->assertTrue($this->extractor->getReadInfo(HookedProperties::class, 'hookGetOnly')->hasHook());
796+
$this->assertFalse($this->extractor->getReadInfo(HookedProperties::class, 'hookGetOnly')->isVirtual());
797+
$this->assertFalse($this->extractor->getWriteInfo(HookedProperties::class, 'hookGetOnly')->hasHook());
798+
$this->assertFalse($this->extractor->getReadInfo(HookedProperties::class, 'hookSetOnly')->hasHook());
799+
$this->assertFalse($this->extractor->getReadInfo(HookedProperties::class, 'hookSetOnly')->isVirtual());
800+
$this->assertTrue($this->extractor->getWriteInfo(HookedProperties::class, 'hookSetOnly')->hasHook());
801+
$this->assertTrue($this->extractor->getReadInfo(HookedProperties::class, 'hookBoth')->hasHook());
802+
$this->assertFalse($this->extractor->getReadInfo(HookedProperties::class, 'hookBoth')->isVirtual());
803+
$this->assertTrue($this->extractor->getWriteInfo(HookedProperties::class, 'hookBoth')->hasHook());
804+
}
805+
783806
public static function provideAsymmetricVisibilityMutator(): iterable
784807
{
785808
yield ['publicPrivate', PropertyReadInfo::VISIBILITY_PUBLIC, PropertyWriteInfo::VISIBILITY_PRIVATE];

0 commit comments

Comments
 (0)