From 540253a89b7a90130f683091558a8b167e425e13 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 12 Feb 2025 17:44:25 +0100 Subject: [PATCH] [VarExporter] Fix lazy objects with hooked properties --- .../VarExporter/Internal/Hydrator.php | 2 + .../Internal/LazyObjectRegistry.php | 17 ++- .../Component/VarExporter/ProxyHelper.php | 109 +++++++++++++++++- .../VarExporter/Tests/Fixtures/Hooked.php | 25 ++++ .../VarExporter/Tests/LazyGhostTraitTest.php | 26 +++++ .../VarExporter/Tests/LazyProxyTraitTest.php | 28 +++++ .../VarExporter/Tests/ProxyHelperTest.php | 12 ++ 7 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 src/Symfony/Component/VarExporter/Tests/Fixtures/Hooked.php diff --git a/src/Symfony/Component/VarExporter/Internal/Hydrator.php b/src/Symfony/Component/VarExporter/Internal/Hydrator.php index 49d636fb8e0ce..97ffe4c831627 100644 --- a/src/Symfony/Component/VarExporter/Internal/Hydrator.php +++ b/src/Symfony/Component/VarExporter/Internal/Hydrator.php @@ -287,6 +287,8 @@ public static function getPropertyScopes($class) if (\ReflectionProperty::IS_PROTECTED & $flags) { $propertyScopes["\0*\0$name"] = $propertyScopes[$name]; + } elseif (\PHP_VERSION_ID >= 80400 && $property->getHooks()) { + $propertyScopes[$name][] = true; } } diff --git a/src/Symfony/Component/VarExporter/Internal/LazyObjectRegistry.php b/src/Symfony/Component/VarExporter/Internal/LazyObjectRegistry.php index fddc6fb3b9664..a7b4987e3b0db 100644 --- a/src/Symfony/Component/VarExporter/Internal/LazyObjectRegistry.php +++ b/src/Symfony/Component/VarExporter/Internal/LazyObjectRegistry.php @@ -50,6 +50,7 @@ class LazyObjectRegistry public static function getClassResetters($class) { $classProperties = []; + $hookedProperties = []; if ((self::$classReflectors[$class] ??= new \ReflectionClass($class))->isInternal()) { $propertyScopes = []; @@ -60,7 +61,13 @@ public static function getClassResetters($class) foreach ($propertyScopes as $key => [$scope, $name, $readonlyScope]) { $propertyScopes[$k = "\0$scope\0$name"] ?? $propertyScopes[$k = "\0*\0$name"] ?? $k = $name; - if ($k === $key && "\0$class\0lazyObjectState" !== $k) { + if ($k !== $key || "\0$class\0lazyObjectState" === $k) { + continue; + } + + if ($k === $name && ($propertyScopes[$k][4] ?? false)) { + $hookedProperties[$k] = true; + } else { $classProperties[$readonlyScope ?? $scope][$name] = $key; } } @@ -76,9 +83,13 @@ public static function getClassResetters($class) }, null, $scope); } - $resetters[] = static function ($instance, $skippedProperties, $onlyProperties = null) { + $resetters[] = static function ($instance, $skippedProperties, $onlyProperties = null) use ($hookedProperties) { foreach ((array) $instance as $name => $value) { - if ("\0" !== ($name[0] ?? '') && !\array_key_exists($name, $skippedProperties) && (null === $onlyProperties || \array_key_exists($name, $onlyProperties))) { + if ("\0" !== ($name[0] ?? '') + && !\array_key_exists($name, $skippedProperties) + && (null === $onlyProperties || \array_key_exists($name, $onlyProperties)) + && !isset($hookedProperties[$name]) + ) { unset($instance->$name); } } diff --git a/src/Symfony/Component/VarExporter/ProxyHelper.php b/src/Symfony/Component/VarExporter/ProxyHelper.php index d5a8d7418b807..246dc4d404bc7 100644 --- a/src/Symfony/Component/VarExporter/ProxyHelper.php +++ b/src/Symfony/Component/VarExporter/ProxyHelper.php @@ -58,6 +58,37 @@ public static function generateLazyGhost(\ReflectionClass $class): string throw new LogicException(sprintf('Cannot generate lazy ghost: class "%s" extends "%s" which is internal.', $class->name, $parent->name)); } } + + $hooks = ''; + $propertyScopes = Hydrator::$propertyScopes[$class->name] ??= Hydrator::getPropertyScopes($class->name); + foreach ($propertyScopes as $name => $scope) { + if (!isset($scope[4]) || ($p = $scope[3])->isVirtual()) { + continue; + } + + $type = self::exportType($p); + $hooks .= "\n public {$type} \${$name} {\n"; + + foreach ($p->getHooks() as $hook => $method) { + if ($method->isFinal()) { + throw new LogicException(sprintf('Cannot generate lazy ghost: hook "%s::%s()" is final.', $class->name, $method->name)); + } + + if ('get' === $hook) { + $ref = ($method->returnsReference() ? '&' : ''); + $hooks .= " {$ref}get { \$this->initializeLazyObject(); return parent::\${$name}::get(); }\n"; + } elseif ('set' === $hook) { + $parameters = self::exportParameters($method, true); + $arg = '$'.$method->getParameters()[0]->name; + $hooks .= " set({$parameters}) { \$this->initializeLazyObject(); parent::\${$name}::set({$arg}); }\n"; + } else { + throw new LogicException(sprintf('Cannot generate lazy ghost: hook "%s::%s()" is not supported.', $class->name, $method->name)); + } + } + + $hooks .= " }\n"; + } + $propertyScopes = self::exportPropertyScopes($class->name); return <<name)); } + $hookedProperties = []; + if (\PHP_VERSION_ID >= 80400 && $class) { + $propertyScopes = Hydrator::$propertyScopes[$class->name] ??= Hydrator::getPropertyScopes($class->name); + foreach ($propertyScopes as $name => $scope) { + if (isset($scope[4]) && !($p = $scope[3])->isVirtual()) { + $hookedProperties[$name] = [$p, $p->getHooks()]; + } + } + } + $methodReflectors = [$class?->getMethods(\ReflectionMethod::IS_PUBLIC | \ReflectionMethod::IS_PROTECTED) ?? []]; foreach ($interfaces as $interface) { if (!$interface->isInterface()) { throw new LogicException(sprintf('Cannot generate lazy proxy: "%s" is not an interface.', $interface->name)); } $methodReflectors[] = $interface->getMethods(); + + if (\PHP_VERSION_ID >= 80400 && !$class) { + foreach ($interface->getProperties() as $p) { + $hookedProperties[$p->name] ??= [$p, []]; + $hookedProperties[$p->name][1] += $p->getHooks(); + } + } + } + + $hooks = ''; + foreach ($hookedProperties as $name => [$p, $methods]) { + $type = self::exportType($p); + $hooks .= "\n public {$type} \${$p->name} {\n"; + + foreach ($methods as $hook => $method) { + if ($method->isFinal()) { + throw new LogicException(sprintf('Cannot generate lazy proxy: hook "%s::%s()" is final.', $class->name, $method->name)); + } + + if ('get' === $hook) { + $ref = ($method->returnsReference() ? '&' : ''); + $hooks .= <<lazyObjectState)) { + return (\$this->lazyObjectState->realInstance ??= (\$this->lazyObjectState->initializer)())->{$p->name}; + } + + return parent::\${$p->name}::get(); + } + + EOPHP; + } elseif ('set' === $hook) { + $parameters = self::exportParameters($method, true); + $arg = '$'.$method->getParameters()[0]->name; + $hooks .= <<lazyObjectState)) { + \$this->lazyObjectState->realInstance ??= (\$this->lazyObjectState->initializer)(); + \$this->lazyObjectState->realInstance->{$p->name} = {$arg}; + } + + parent::\${$p->name}::set({$arg}); + } + + EOPHP; + } else { + throw new LogicException(sprintf('Cannot generate lazy proxy: hook "%s::%s()" is not supported.', $class->name, $method->name)); + } + } + + $hooks .= " }\n"; } - $methodReflectors = array_merge(...$methodReflectors); $extendsInternalClass = false; if ($parent = $class) { @@ -112,6 +203,7 @@ public static function generateLazyProxy(?\ReflectionClass $class, array $interf } $methodsHaveToBeProxied = $extendsInternalClass; $methods = []; + $methodReflectors = array_merge(...$methodReflectors); foreach ($methodReflectors as $method) { if ('__get' !== strtolower($method->name) || 'mixed' === ($type = self::exportType($method) ?? 'mixed')) { @@ -228,7 +320,7 @@ public function __unserialize(\$data): void {$lazyProxyTraitStatement} private const LAZY_OBJECT_PROPERTY_SCOPES = {$propertyScopes}; - {$body}} + {$hooks}{$body}} // Help opcache.preload discover always-needed symbols class_exists(\Symfony\Component\VarExporter\Internal\Hydrator::class); @@ -238,7 +330,7 @@ class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectState::class); EOPHP; } - public static function exportSignature(\ReflectionFunctionAbstract $function, bool $withParameterTypes = true, ?string &$args = null): string + public static function exportParameters(\ReflectionFunctionAbstract $function, bool $withParameterTypes = true, ?string &$args = null): string { $byRefIndex = 0; $args = ''; @@ -268,8 +360,15 @@ public static function exportSignature(\ReflectionFunctionAbstract $function, bo $args = implode(', ', $args); } + return implode(', ', $parameters); + } + + public static function exportSignature(\ReflectionFunctionAbstract $function, bool $withParameterTypes = true, ?string &$args = null): string + { + $parameters = self::exportParameters($function, $withParameterTypes, $args); + $signature = 'function '.($function->returnsReference() ? '&' : '') - .($function->isClosure() ? '' : $function->name).'('.implode(', ', $parameters).')'; + .($function->isClosure() ? '' : $function->name).'('.$parameters.')'; if ($function instanceof \ReflectionMethod) { $signature = ($function->isPublic() ? 'public ' : ($function->isProtected() ? 'protected ' : 'private ')) diff --git a/src/Symfony/Component/VarExporter/Tests/Fixtures/Hooked.php b/src/Symfony/Component/VarExporter/Tests/Fixtures/Hooked.php new file mode 100644 index 0000000000000..0c46d37afe922 --- /dev/null +++ b/src/Symfony/Component/VarExporter/Tests/Fixtures/Hooked.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Tests\Fixtures; + +class Hooked +{ + public int $notBacked { + get { return 123; } + set { throw \LogicException('Cannot set value.'); } + } + + public int $backed { + get { return $this->backed ??= 234; } + set { $this->backed = $value; } + } +} diff --git a/src/Symfony/Component/VarExporter/Tests/LazyGhostTraitTest.php b/src/Symfony/Component/VarExporter/Tests/LazyGhostTraitTest.php index 68e76a7dac1fa..00f090a43c292 100644 --- a/src/Symfony/Component/VarExporter/Tests/LazyGhostTraitTest.php +++ b/src/Symfony/Component/VarExporter/Tests/LazyGhostTraitTest.php @@ -17,6 +17,7 @@ use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\VarExporter\Internal\LazyObjectState; use Symfony\Component\VarExporter\ProxyHelper; +use Symfony\Component\VarExporter\Tests\Fixtures\Hooked; use Symfony\Component\VarExporter\Tests\Fixtures\LazyGhost\ChildMagicClass; use Symfony\Component\VarExporter\Tests\Fixtures\LazyGhost\ChildStdClass; use Symfony\Component\VarExporter\Tests\Fixtures\LazyGhost\ChildTestClass; @@ -478,6 +479,31 @@ public function testNormalization() $this->assertSame(['property' => 'property', 'method' => 'method'], $output); } + /** + * @requires PHP 8.4 + */ + public function testPropertyHooks() + { + $initialized = false; + $object = $this->createLazyGhost(Hooked::class, function ($instance) use (&$initialized) { + $initialized = true; + }); + + $this->assertSame(123, $object->notBacked); + $this->assertFalse($initialized); + $this->assertSame(234, $object->backed); + $this->assertTrue($initialized); + + $initialized = false; + $object = $this->createLazyGhost(Hooked::class, function ($instance) use (&$initialized) { + $initialized = true; + }); + + $object->backed = 345; + $this->assertTrue($initialized); + $this->assertSame(345, $object->backed); + } + /** * @template T * diff --git a/src/Symfony/Component/VarExporter/Tests/LazyProxyTraitTest.php b/src/Symfony/Component/VarExporter/Tests/LazyProxyTraitTest.php index c4234d085b6dc..938b304461291 100644 --- a/src/Symfony/Component/VarExporter/Tests/LazyProxyTraitTest.php +++ b/src/Symfony/Component/VarExporter/Tests/LazyProxyTraitTest.php @@ -18,6 +18,7 @@ use Symfony\Component\VarExporter\Exception\LogicException; use Symfony\Component\VarExporter\LazyProxyTrait; use Symfony\Component\VarExporter\ProxyHelper; +use Symfony\Component\VarExporter\Tests\Fixtures\Hooked; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\FinalPublicClass; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\ReadOnlyClass; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\StringMagicGetClass; @@ -298,6 +299,33 @@ public function testNormalization() $this->assertSame(['property' => 'property', 'method' => 'method'], $output); } + /** + * @requires PHP 8.4 + */ + public function testPropertyHooks() + { + $initialized = false; + $object = $this->createLazyProxy(Hooked::class, function () use (&$initialized) { + $initialized = true; + return new Hooked(); + }); + + $this->assertSame(123, $object->notBacked); + $this->assertFalse($initialized); + $this->assertSame(234, $object->backed); + $this->assertTrue($initialized); + + $initialized = false; + $object = $this->createLazyProxy(Hooked::class, function () use (&$initialized) { + $initialized = true; + return new Hooked(); + }); + + $object->backed = 345; + $this->assertTrue($initialized); + $this->assertSame(345, $object->backed); + } + /** * @template T * diff --git a/src/Symfony/Component/VarExporter/Tests/ProxyHelperTest.php b/src/Symfony/Component/VarExporter/Tests/ProxyHelperTest.php index b7372632de217..d0085a70498c5 100644 --- a/src/Symfony/Component/VarExporter/Tests/ProxyHelperTest.php +++ b/src/Symfony/Component/VarExporter/Tests/ProxyHelperTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\VarExporter\Exception\LogicException; use Symfony\Component\VarExporter\ProxyHelper; +use Symfony\Component\VarExporter\Tests\Fixtures\Hooked; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\Php82NullStandaloneReturnType; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\StringMagicGetClass; @@ -246,6 +247,17 @@ public function testNullStandaloneReturnType() ProxyHelper::generateLazyProxy(new \ReflectionClass(Php82NullStandaloneReturnType::class)) ); } + + /** + * @requires PHP 8.4 + */ + public function testPropertyHooks() + { + self::assertStringContainsString( + "[parent::class, 'backed', null, 4 => true]", + ProxyHelper::generateLazyProxy(new \ReflectionClass(Hooked::class)) + ); + } } abstract class TestForProxyHelper