Skip to content

Commit 0900ef7

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

File tree

11 files changed

+180
-13
lines changed

11 files changed

+180
-13
lines changed

src/Symfony/Component/PropertyAccess/CHANGELOG.md

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

4+
7.3
5+
---
6+
* Allow to customize behavior for property hooks on read and write
7+
48
7.0
59
---
610

src/Symfony/Component/PropertyAccess/PropertyAccessor.php

Lines changed: 26 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 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;
@@ -84,6 +88,7 @@ public function __construct(
8488
?CacheItemPoolInterface $cacheItemPool = null,
8589
?PropertyReadInfoExtractorInterface $readInfoExtractor = null,
8690
?PropertyWriteInfoExtractorInterface $writeInfoExtractor = null,
91+
private int $byPassHooksOnProperty = self::DO_NOT_BYPASS_HOOKS_ON_PROPERTY,
8792
) {
8893
$this->ignoreInvalidIndices = 0 === ($throw & self::THROW_ON_INVALID_INDEX);
8994
$this->cacheItemPool = $cacheItemPool instanceof NullAdapter ? null : $cacheItemPool; // Replace the NullAdapter by the null value
@@ -414,12 +419,21 @@ private function readProperty(array $zval, string $property, bool $ignoreInvalid
414419
throw $e;
415420
}
416421
} elseif (PropertyReadInfo::TYPE_PROPERTY === $type) {
417-
if (!isset($object->$name) && !\array_key_exists($name, (array) $object)) {
422+
$valueSet = false;
423+
$useBypass = $this->byPassHooksOnProperty & self::BYPASS_HOOKS_ON_PROPERTY_READ && $access->hasHook() && !$access->isVirtual();
424+
$valueSeemsToBeNotSet = !isset($object->$name) && !\array_key_exists($name, (array) $object);
425+
if ($valueSeemsToBeNotSet || $useBypass) {
418426
try {
419427
$r = new \ReflectionProperty($class, $name);
428+
if ($valueSeemsToBeNotSet) {
429+
if ($r->isPublic() && !$r->hasType()) {
430+
throw new UninitializedPropertyException(\sprintf('The property "%s::$%s" is not initialized.', $class, $name));
431+
}
432+
}
420433

421-
if ($r->isPublic() && !$r->hasType()) {
422-
throw new UninitializedPropertyException(\sprintf('The property "%s::$%s" is not initialized.', $class, $name));
434+
if ($useBypass) {
435+
$result[self::VALUE] = $r->getRawValue($object);
436+
$valueSet = true;
423437
}
424438
} catch (\ReflectionException $e) {
425439
if (!$ignoreInvalidProperty) {
@@ -428,7 +442,9 @@ private function readProperty(array $zval, string $property, bool $ignoreInvalid
428442
}
429443
}
430444

431-
$result[self::VALUE] = $object->$name;
445+
if (!$valueSet) {
446+
$result[self::VALUE] = $object->$name;
447+
}
432448

433449
if (isset($zval[self::REF]) && $access->canBeReference()) {
434450
$result[self::REF] = &$object->$name;
@@ -531,7 +547,12 @@ private function writeProperty(array $zval, string $property, mixed $value, bool
531547
if (PropertyWriteInfo::TYPE_METHOD === $type) {
532548
$object->{$mutator->getName()}($value);
533549
} elseif (PropertyWriteInfo::TYPE_PROPERTY === $type) {
534-
$object->{$mutator->getName()} = $value;
550+
if ($this->byPassHooksOnProperty & self::BYPASS_HOOKS_ON_PROPERTY_WRITE) {
551+
$r = new \ReflectionProperty($class, $mutator->getName());
552+
$r->setRawValue($object, $value);
553+
} else {
554+
$object->{$mutator->getName()} = $value;
555+
}
535556
} elseif (PropertyWriteInfo::TYPE_ADDER_AND_REMOVER === $type) {
536557
$this->writeCollection($zval, $property, $value, $mutator->getAdderInfo(), $mutator->getRemoverInfo());
537558
}
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(byPassHooksOnProperty: 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(byPassHooksOnProperty: 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/PropertyInfo/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ CHANGELOG
33

44
7.3
55
---
6-
6+
* Gather data from property hooks in ReflectionExtractor
77
* Add support for `non-positive-int`, `non-negative-int` and `non-zero-int` PHPStan types to `PhpStanExtractor`
88

99
7.1

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

Lines changed: 13 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->propertyHasHook($r, 'get') && $r->isVirtual());
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,14 @@ private function isMethodAccessible(\ReflectionClass $class, string $methodName,
885885
return [false, $errors];
886886
}
887887

888+
private function propertyHasHook(\ReflectionProperty $property, string $hookType): bool
889+
{
890+
if (!class_exists(\PropertyHookType::class)) {
891+
return false;
892+
}
893+
return $property->hasHook(\PropertyHookType::from($hookType));
894+
}
895+
888896
/**
889897
* Camelizes a given string.
890898
*/

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];
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)