Skip to content

Commit 3990cad

Browse files
committed
[PropertyInfo][PropertyAccess] Feature: customize behavior for property hooks on read and write
1 parent 79fa5f2 commit 3990cad

File tree

12 files changed

+188
-14
lines changed

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_READ = 1 << 1;
52+
public const BYPASS_PROPERTY_HOOK_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,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 $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];

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy