Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[VarExporter] Fix lazy objects with hooked properties #59761

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Symfony/Component/VarExporter/Internal/Hydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@

if (\ReflectionProperty::IS_PROTECTED & $flags) {
$propertyScopes["\0*\0$name"] = $propertyScopes[$name];
} elseif (\PHP_VERSION_ID >= 80400 && $property->getHooks()) {

Check failure on line 290 in src/Symfony/Component/VarExporter/Internal/Hydrator.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedMethod

src/Symfony/Component/VarExporter/Internal/Hydrator.php:290:62: UndefinedMethod: Method ReflectionProperty::getHooks does not exist (see https://psalm.dev/022)

Check failure on line 290 in src/Symfony/Component/VarExporter/Internal/Hydrator.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedMethod

src/Symfony/Component/VarExporter/Internal/Hydrator.php:290:62: UndefinedMethod: Method ReflectionProperty::getHooks does not exist (see https://psalm.dev/022)
$propertyScopes[$name][] = true;
}
}

Expand Down
17 changes: 14 additions & 3 deletions src/Symfony/Component/VarExporter/Internal/LazyObjectRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class LazyObjectRegistry
public static function getClassResetters($class)
{
$classProperties = [];
$hookedProperties = [];

if ((self::$classReflectors[$class] ??= new \ReflectionClass($class))->isInternal()) {
$propertyScopes = [];
Expand All @@ -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;
}
}
Expand All @@ -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);
}
}
Expand Down
109 changes: 104 additions & 5 deletions src/Symfony/Component/VarExporter/ProxyHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,37 @@
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) {

Check failure on line 72 in src/Symfony/Component/VarExporter/ProxyHelper.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedMethod

src/Symfony/Component/VarExporter/ProxyHelper.php:72:26: UndefinedMethod: Method ReflectionParameter::getHooks does not exist (see https://psalm.dev/022)

Check failure on line 72 in src/Symfony/Component/VarExporter/ProxyHelper.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedMethod

src/Symfony/Component/VarExporter/ProxyHelper.php:72:26: UndefinedMethod: Method ReflectionParameter::getHooks does not exist (see https://psalm.dev/022)
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 <<<EOPHP
Expand All @@ -66,7 +97,7 @@
use \Symfony\Component\VarExporter\LazyGhostTrait;

private const LAZY_OBJECT_PROPERTY_SCOPES = {$propertyScopes};
}
{$hooks}}

// Help opcache.preload discover always-needed symbols
class_exists(\Symfony\Component\VarExporter\Internal\Hydrator::class);
Expand Down Expand Up @@ -95,14 +126,74 @@
throw new LogicException(sprintf('Cannot generate lazy proxy with PHP < 8.3: class "%s" is readonly.', $class->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();

Check failure on line 149 in src/Symfony/Component/VarExporter/ProxyHelper.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedMethod

src/Symfony/Component/VarExporter/ProxyHelper.php:149:59: UndefinedMethod: Method ReflectionProperty::getHooks does not exist (see https://psalm.dev/022)

Check failure on line 149 in src/Symfony/Component/VarExporter/ProxyHelper.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedMethod

src/Symfony/Component/VarExporter/ProxyHelper.php:149:59: UndefinedMethod: Method ReflectionProperty::getHooks does not exist (see https://psalm.dev/022)
}
}
}

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

Check failure on line 161 in src/Symfony/Component/VarExporter/ProxyHelper.php

View workflow job for this annotation

GitHub Actions / Psalm

NullPropertyFetch

src/Symfony/Component/VarExporter/ProxyHelper.php:161:111: NullPropertyFetch: Cannot get property on null variable $class (see https://psalm.dev/027)

Check failure on line 161 in src/Symfony/Component/VarExporter/ProxyHelper.php

View workflow job for this annotation

GitHub Actions / Psalm

NullPropertyFetch

src/Symfony/Component/VarExporter/ProxyHelper.php:161:111: NullPropertyFetch: Cannot get property on null variable $class (see https://psalm.dev/027)
}

if ('get' === $hook) {
$ref = ($method->returnsReference() ? '&' : '');
$hooks .= <<<EOPHP
{$ref}get {
if (isset(\$this->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 .= <<<EOPHP
set({$parameters}) {
if (isset(\$this->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));

Check failure on line 191 in src/Symfony/Component/VarExporter/ProxyHelper.php

View workflow job for this annotation

GitHub Actions / Psalm

NullPropertyFetch

src/Symfony/Component/VarExporter/ProxyHelper.php:191:119: NullPropertyFetch: Cannot get property on null variable $class (see https://psalm.dev/027)

Check failure on line 191 in src/Symfony/Component/VarExporter/ProxyHelper.php

View workflow job for this annotation

GitHub Actions / Psalm

NullPropertyFetch

src/Symfony/Component/VarExporter/ProxyHelper.php:191:119: NullPropertyFetch: Cannot get property on null variable $class (see https://psalm.dev/027)
}
}

$hooks .= " }\n";
}
$methodReflectors = array_merge(...$methodReflectors);

$extendsInternalClass = false;
if ($parent = $class) {
Expand All @@ -112,6 +203,7 @@
}
$methodsHaveToBeProxied = $extendsInternalClass;
$methods = [];
$methodReflectors = array_merge(...$methodReflectors);

foreach ($methodReflectors as $method) {
if ('__get' !== strtolower($method->name) || 'mixed' === ($type = self::exportType($method) ?? 'mixed')) {
Expand Down Expand Up @@ -228,7 +320,7 @@
{$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);
Expand All @@ -238,7 +330,7 @@
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 = '';
Expand Down Expand Up @@ -268,8 +360,15 @@
$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 '))
Expand Down
25 changes: 25 additions & 0 deletions src/Symfony/Component/VarExporter/Tests/Fixtures/Hooked.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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; }
}
}
26 changes: 26 additions & 0 deletions src/Symfony/Component/VarExporter/Tests/LazyGhostTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*
Expand Down
28 changes: 28 additions & 0 deletions src/Symfony/Component/VarExporter/Tests/LazyProxyTraitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*
Expand Down
12 changes: 12 additions & 0 deletions src/Symfony/Component/VarExporter/Tests/ProxyHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
Loading