Skip to content

Commit e4901fb

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

12 files changed

+188
-14
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: 29 additions & 5 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 BYPASS_PROPERTY_HOOK_NONE = 0;
51+
public const BYPASS_PROPERTY_HOOK_WRITE = 1 << 0;
52+
public const BYPASS_PROPERTY_HOOK_READ = 1 << 1;
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,6 +71,7 @@ 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
@@ -77,19 +82,24 @@ class PropertyAccessor implements PropertyAccessorInterface
7782
* or self::DISALLOW_MAGIC_METHODS for none
7883
* @param int $throw A bitwise combination of the THROW_* constants
7984
* to specify when exceptions should be thrown
85+
* @param int-mask-of<self::BYPASS_PROPERTY_HOOK_*> $bypassPropertyHooks A bitwise combination of the BYPASS_PROPERTY_HOOK_* constants
86+
* to specify the hooks you want to bypass,
87+
* or self::BYPASS_PROPERTY_HOOK_NONE 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::BYPASS_PROPERTY_HOOK_NONE,
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::BYPASS_PROPERTY_HOOK_NONE;
93103
}
94104

95105
public function getValue(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): mixed
@@ -414,21 +424,30 @@ 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+
$bypassHooks = $this->bypassPropertyHooks & self::BYPASS_PROPERTY_HOOK_READ && $access->hasHook() && !$access->isVirtual();
429+
$initialValueNotSet = !isset($object->$name) && !\array_key_exists($name, (array) $object);
430+
if ($initialValueNotSet || $bypassHooks) {
418431
try {
419432
$r = new \ReflectionProperty($class, $name);
420-
421-
if ($r->isPublic() && !$r->hasType()) {
433+
if ($initialValueNotSet && $r->isPublic() && !$r->hasType()) {
422434
throw new UninitializedPropertyException(\sprintf('The property "%s::$%s" is not initialized.', $class, $name));
423435
}
436+
437+
if ($bypassHooks) {
438+
$result[self::VALUE] = $r->getRawValue($object);
439+
$valueSet = true;
440+
}
424441
} catch (\ReflectionException $e) {
425442
if (!$ignoreInvalidProperty) {
426443
throw new NoSuchPropertyException(\sprintf('Can\'t get a way to read the property "%s" in class "%s".', $property, $class));
427444
}
428445
}
429446
}
430447

431-
$result[self::VALUE] = $object->$name;
448+
if (!$valueSet) {
449+
$result[self::VALUE] = $object->$name;
450+
}
432451

433452
if (isset($zval[self::REF]) && $access->canBeReference()) {
434453
$result[self::REF] = &$object->$name;
@@ -531,7 +550,12 @@ private function writeProperty(array $zval, string $property, mixed $value, bool
531550
if (PropertyWriteInfo::TYPE_METHOD === $type) {
532551
$object->{$mutator->getName()}($value);
533552
} elseif (PropertyWriteInfo::TYPE_PROPERTY === $type) {
534-
$object->{$mutator->getName()} = $value;
553+
if ($this->bypassPropertyHooks & self::BYPASS_PROPERTY_HOOK_WRITE) {
554+
$r = new \ReflectionProperty($class, $mutator->getName());
555+
$r->setRawValue($object, $value);
556+
} else {
557+
$object->{$mutator->getName()} = $value;
558+
}
535559
} elseif (PropertyWriteInfo::TYPE_ADDER_AND_REMOVER === $type) {
536560
$this->writeCollection($zval, $property, $value, $mutator->getAdderInfo(), $mutator->getRemoverInfo());
537561
}
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
@@ -26,6 +26,7 @@
2626
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestAdderRemoverInvalidArgumentLength;
2727
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestAdderRemoverInvalidMethods;
2828
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestClass;
29+
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestClassHooks;
2930
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestClassIsWritable;
3031
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestClassMagicCall;
3132
use Symfony\Component\PropertyAccess\Tests\Fixtures\TestClassMagicGet;
@@ -1030,6 +1031,45 @@ public function testIsReadableWithMissingPropertyAndLazyGhost()
10301031
$this->assertFalse($this->propertyAccessor->isReadable($lazyGhost, 'dummy'));
10311032
}
10321033

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

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
* Add `PropertyDescriptionExtractorInterface` to `PhpStanExtractor`
910
* Deprecate the `Type` class, use `Symfony\Component\TypeInfo\Type` class from `symfony/type-info` instead

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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -392,12 +392,12 @@ public function getReadInfo(string $class, string $property, array $context = []
392392
return new PropertyReadInfo(PropertyReadInfo::TYPE_METHOD, $getsetter, $this->getReadVisibilityForMethod($method), $method->isStatic(), false);
393393
}
394394

395-
if ($allowMagicGet && $reflClass->hasMethod('__get') && (($r = $reflClass->getMethod('__get'))->getModifiers() & $this->methodReflectionFlags)) {
396-
return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, PropertyReadInfo::VISIBILITY_PUBLIC, false, $r->returnsReference());
395+
if ($hasProperty && (($r = $reflClass->getProperty($property))->getModifiers() & $this->propertyReflectionFlags)) {
396+
return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, $this->getReadVisibilityForProperty($r), $r->isStatic(), true, $this->propertyHasHook($r, 'get'), $this->propertyIsVirtual($r));
397397
}
398398

399-
if ($hasProperty && (($r = $reflClass->getProperty($property))->getModifiers() & $this->propertyReflectionFlags)) {
400-
return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, $this->getReadVisibilityForProperty($r), $r->isStatic(), true);
399+
if ($allowMagicGet && $reflClass->hasMethod('__get') && (($r = $reflClass->getMethod('__get'))->getModifiers() & $this->methodReflectionFlags)) {
400+
return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, PropertyReadInfo::VISIBILITY_PUBLIC, false, $r->returnsReference(), false, false);
401401
}
402402

403403
if ($allowMagicCall && $reflClass->hasMethod('__call') && ($reflClass->getMethod('__call')->getModifiers() & $this->methodReflectionFlags)) {
@@ -481,7 +481,7 @@ public function getWriteInfo(string $class, string $property, array $context = [
481481
if ($reflClass->hasProperty($property) && ($reflClass->getProperty($property)->getModifiers() & $this->propertyReflectionFlags)) {
482482
$reflProperty = $reflClass->getProperty($property);
483483
if (!$reflProperty->isReadOnly()) {
484-
return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, $this->getWriteVisibilityForProperty($reflProperty), $reflProperty->isStatic());
484+
return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, $this->getWriteVisibilityForProperty($reflProperty), $reflProperty->isStatic(), $this->propertyHasHook($reflProperty, 'set'));
485485
}
486486

487487
$errors[] = [\sprintf('The property "%s" in class "%s" is a promoted readonly property.', $property, $reflClass->getName())];
@@ -491,7 +491,7 @@ public function getWriteInfo(string $class, string $property, array $context = [
491491
if ($allowMagicSet) {
492492
[$accessible, $methodAccessibleErrors] = $this->isMethodAccessible($reflClass, '__set', 2);
493493
if ($accessible) {
494-
return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, PropertyWriteInfo::VISIBILITY_PUBLIC, false);
494+
return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, PropertyWriteInfo::VISIBILITY_PUBLIC, false, false);
495495
}
496496

497497
$errors[] = $methodAccessibleErrors;
@@ -894,6 +894,16 @@ private function isMethodAccessible(\ReflectionClass $class, string $methodName,
894894
return [false, $errors];
895895
}
896896

897+
private function propertyHasHook(\ReflectionProperty $property, string $hookType): bool
898+
{
899+
return \PHP_VERSION_ID >= 80400 && $property->hasHook(\PropertyHookType::from($hookType));
900+
}
901+
902+
private function propertyIsVirtual(\ReflectionProperty $property): bool
903+
{
904+
return \PHP_VERSION_ID >= 80400 && $property->isVirtual();
905+
}
906+
897907
/**
898908
* Camelizes a given string.
899909
*/

src/Symfony/Component/PropertyInfo/PropertyReadInfo.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ public function __construct(
3131
private readonly string $visibility,
3232
private readonly bool $static,
3333
private readonly bool $byRef,
34+
private readonly ?bool $hasHook = null,
35+
private readonly ?bool $isVirtual = null,
3436
) {
3537
}
3638

@@ -67,4 +69,14 @@ public function canBeReference(): bool
6769
{
6870
return $this->byRef;
6971
}
72+
73+
public function hasHook(): ?bool
74+
{
75+
return $this->hasHook;
76+
}
77+
78+
public function isVirtual(): ?bool
79+
{
80+
return $this->isVirtual;
81+
}
7082
}

src/Symfony/Component/PropertyInfo/PropertyWriteInfo.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public function __construct(
3737
private readonly ?string $name = null,
3838
private readonly ?string $visibility = null,
3939
private readonly ?bool $static = null,
40+
private readonly ?bool $hasHook = null,
4041
) {
4142
}
4243

@@ -114,4 +115,9 @@ public function hasErrors(): bool
114115
{
115116
return (bool) \count($this->errors);
116117
}
118+
119+
public function hasHook(): ?bool
120+
{
121+
return $this->hasHook;
122+
}
117123
}

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Symfony\Component\PropertyInfo\Tests\Fixtures\ConstructorDummy;
2222
use Symfony\Component\PropertyInfo\Tests\Fixtures\DefaultValue;
2323
use Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy;
24+
use Symfony\Component\PropertyInfo\Tests\Fixtures\HookedProperties;
2425
use Symfony\Component\PropertyInfo\Tests\Fixtures\NotInstantiable;
2526
use Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy;
2627
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php71Dummy;
@@ -788,8 +789,14 @@ public function testAsymmetricVisibilityAllowPrivateOnly()
788789
public function testVirtualProperties()
789790
{
790791
$this->assertTrue($this->extractor->isReadable(VirtualProperties::class, 'virtualNoSetHook'));
792+
$this->assertTrue($this->extractor->getReadInfo(VirtualProperties::class, 'virtualNoSetHook')->isVirtual());
793+
$this->assertTrue($this->extractor->getReadInfo(VirtualProperties::class, 'virtualNoSetHook')->hasHook());
791794
$this->assertTrue($this->extractor->isReadable(VirtualProperties::class, 'virtualSetHookOnly'));
795+
$this->assertTrue($this->extractor->getReadInfo(VirtualProperties::class, 'virtualSetHookOnly')->isVirtual());
796+
$this->assertFalse($this->extractor->getReadInfo(VirtualProperties::class, 'virtualSetHookOnly')->hasHook());
792797
$this->assertTrue($this->extractor->isReadable(VirtualProperties::class, 'virtualHook'));
798+
$this->assertTrue($this->extractor->getReadInfo(VirtualProperties::class, 'virtualHook')->isVirtual());
799+
$this->assertTrue($this->extractor->getReadInfo(VirtualProperties::class, 'virtualHook')->hasHook());
793800
$this->assertFalse($this->extractor->isWritable(VirtualProperties::class, 'virtualNoSetHook'));
794801
$this->assertTrue($this->extractor->isWritable(VirtualProperties::class, 'virtualSetHookOnly'));
795802
$this->assertTrue($this->extractor->isWritable(VirtualProperties::class, 'virtualHook'));
@@ -814,6 +821,22 @@ public function testAsymmetricVisibilityMutator(string $property, string $readVi
814821
$this->assertSame($writeVisibility, $writeMutator->getVisibility());
815822
}
816823

824+
/**
825+
* @requires PHP 8.4
826+
*/
827+
public function testHookedProperties()
828+
{
829+
$this->assertTrue($this->extractor->getReadInfo(HookedProperties::class, 'hookGetOnly')->hasHook());
830+
$this->assertFalse($this->extractor->getReadInfo(HookedProperties::class, 'hookGetOnly')->isVirtual());
831+
$this->assertFalse($this->extractor->getWriteInfo(HookedProperties::class, 'hookGetOnly')->hasHook());
832+
$this->assertFalse($this->extractor->getReadInfo(HookedProperties::class, 'hookSetOnly')->hasHook());
833+
$this->assertFalse($this->extractor->getReadInfo(HookedProperties::class, 'hookSetOnly')->isVirtual());
834+
$this->assertTrue($this->extractor->getWriteInfo(HookedProperties::class, 'hookSetOnly')->hasHook());
835+
$this->assertTrue($this->extractor->getReadInfo(HookedProperties::class, 'hookBoth')->hasHook());
836+
$this->assertFalse($this->extractor->getReadInfo(HookedProperties::class, 'hookBoth')->isVirtual());
837+
$this->assertTrue($this->extractor->getWriteInfo(HookedProperties::class, 'hookBoth')->hasHook());
838+
}
839+
817840
public static function provideAsymmetricVisibilityMutator(): iterable
818841
{
819842
yield ['publicPrivate', PropertyReadInfo::VISIBILITY_PUBLIC, PropertyWriteInfo::VISIBILITY_PRIVATE];
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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\Tests\Fixtures;
13+
14+
class HookedProperties
15+
{
16+
public string $hookGetOnly {
17+
get => $this->hookGetOnly . ' (hooked on get)';
18+
}
19+
public string $hookSetOnly {
20+
set(string $value) {
21+
$this->hookSetOnly = $value . ' (hooked on set)';
22+
}
23+
}
24+
public string $hookBoth {
25+
get => $this->hookBoth . ' (hooked on get)';
26+
set(string $value) {
27+
$this->hookBoth = $value . ' (hooked on set)';
28+
}
29+
}
30+
}

src/Symfony/Component/PropertyInfo/Tests/Fixtures/VirtualProperties.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@
1414
class VirtualProperties
1515
{
1616
public bool $virtualNoSetHook { get => true; }
17-
public bool $virtualSetHookOnly { set => $value; }
18-
public bool $virtualHook { get => true; set => $value; }
17+
public bool $virtualSetHookOnly { set (bool $value) { } }
18+
public bool $virtualHook { get => true; set (bool $value) { } }
1919
}

0 commit comments

Comments
 (0)