diff --git a/CHANGELOG.md b/CHANGELOG.md index 74333ea..50b6d35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ CHANGELOG ========= +7.3 +--- + + * Deprecate using `ProxyHelper::generateLazyProxy()` when native lazy proxies can be used - the method should be used to generate abstraction-based lazy decorators only + * Deprecate `LazyGhostTrait` and `LazyProxyTrait`, use native lazy objects instead + * Deprecate `ProxyHelper::generateLazyGhost()`, use native lazy objects instead + 7.2 --- diff --git a/Internal/LazyDecoratorTrait.php b/Internal/LazyDecoratorTrait.php new file mode 100644 index 0000000..f05ca75 --- /dev/null +++ b/Internal/LazyDecoratorTrait.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Internal; + +use Symfony\Component\Serializer\Attribute\Ignore; +use Symfony\Component\VarExporter\Internal\LazyObjectRegistry as Registry; + +/** + * @internal + */ +trait LazyDecoratorTrait +{ + #[Ignore] + private readonly LazyObjectState $lazyObjectState; + + /** + * Creates a lazy-loading decorator. + * + * @param \Closure():object $initializer Returns the proxied object + * @param static|null $instance + */ + public static function createLazyProxy(\Closure $initializer, ?object $instance = null): static + { + $class = $instance ? $instance::class : static::class; + + if (self::class === $class && \defined($class.'::LAZY_OBJECT_PROPERTY_SCOPES')) { + Hydrator::$propertyScopes[$class] ??= $class::LAZY_OBJECT_PROPERTY_SCOPES; + } + + $instance ??= (Registry::$classReflectors[$class] ??= ($r = new \ReflectionClass($class))->hasProperty('lazyObjectState') + ? $r + : throw new \LogicException('Cannot create a lazy proxy for a non-decorator object.') + )->newInstanceWithoutConstructor(); + + $state = $instance->lazyObjectState ??= new LazyObjectState(); + $state->initializer = null; + unset($state->realInstance); + + foreach (Registry::$classResetters[$class] ??= Registry::getClassResetters($class) as $reset) { + $reset($instance, []); + } + $state->initializer = $initializer; + + return $instance; + } + + public function __construct(...$args) + { + self::createLazyProxy(static fn () => new parent(...$args), $this); + } + + public function __destruct() + { + } + + #[Ignore] + public function isLazyObjectInitialized(bool $partial = false): bool + { + return isset($this->lazyObjectState->realInstance); + } + + public function initializeLazyObject(): parent + { + return $this->lazyObjectState->realInstance; + } + + public function resetLazyObject(): bool + { + if (!isset($this->lazyObjectState->initializer)) { + return false; + } + unset($this->lazyObjectState->realInstance); + + return true; + } + + public function &__get($name): mixed + { + $instance = $this->lazyObjectState->realInstance; + $class = $this::class; + + $propertyScopes = Hydrator::$propertyScopes[$class] ??= Hydrator::getPropertyScopes($class); + $notByRef = 0; + + if ([, , , $access] = $propertyScopes[$name] ?? null) { + $notByRef = $access & Hydrator::PROPERTY_NOT_BY_REF || ($access >> 2) & \ReflectionProperty::IS_PRIVATE_SET; + } + + if ($notByRef || 2 !== ((Registry::$parentMethods[$class] ??= Registry::getParentMethods($class))['get'] ?: 2)) { + $value = $instance->$name; + + return $value; + } + + try { + return $instance->$name; + } catch (\Error $e) { + if (\Error::class !== $e::class || !str_starts_with($e->getMessage(), 'Cannot access uninitialized non-nullable property')) { + throw $e; + } + + try { + $instance->$name = []; + + return $instance->$name; + } catch (\Error) { + if (preg_match('/^Cannot access uninitialized non-nullable property ([^ ]++) by reference$/', $e->getMessage(), $matches)) { + throw new \Error('Typed property '.$matches[1].' must not be accessed before initialization', $e->getCode(), $e->getPrevious()); + } + + throw $e; + } + } + } + + public function __set($name, $value): void + { + $this->lazyObjectState->realInstance->$name = $value; + } + + public function __isset($name): bool + { + return isset($this->lazyObjectState->realInstance->$name); + } + + public function __unset($name): void + { + if ($this->lazyObjectState->initializer) { + unset($this->lazyObjectState->realInstance->$name); + } + } + + public function __serialize(): array + { + return [$this->lazyObjectState->realInstance]; + } + + public function __unserialize($data): void + { + $this->lazyObjectState = new LazyObjectState(); + $this->lazyObjectState->realInstance = $data[0]; + } + + public function __clone(): void + { + $this->lazyObjectState->realInstance; // initialize lazy object + $this->lazyObjectState = clone $this->lazyObjectState; + } +} diff --git a/Internal/LazyObjectState.php b/Internal/LazyObjectState.php index ac16d13..138aa74 100644 --- a/Internal/LazyObjectState.php +++ b/Internal/LazyObjectState.php @@ -33,12 +33,13 @@ class LazyObjectState public int $status = self::STATUS_UNINITIALIZED_FULL; public object $realInstance; + public object $cloneInstance; /** * @param array $skippedProperties */ public function __construct( - public \Closure $initializer, + public ?\Closure $initializer = null, public array $skippedProperties = [], ) { } @@ -94,4 +95,26 @@ public function reset($instance): void $this->status = self::STATUS_UNINITIALIZED_FULL; } + + public function __clone() + { + if (isset($this->cloneInstance)) { + try { + $this->realInstance = $this->cloneInstance; + } finally { + unset($this->cloneInstance); + } + } elseif (isset($this->realInstance)) { + $this->realInstance = clone $this->realInstance; + } + } + + public function __get($name) + { + if ('realInstance' !== $name) { + throw new \BadMethodCallException(\sprintf('No such property "%s::$%s"', self::class, $name)); + } + + return $this->realInstance = ($this->initializer)(); + } } diff --git a/Internal/Registry.php b/Internal/Registry.php index 9c41684..f057b74 100644 --- a/Internal/Registry.php +++ b/Internal/Registry.php @@ -58,7 +58,7 @@ public static function f($class) { $reflector = self::$reflectors[$class] ??= self::getClassReflector($class, true, false); - return self::$factories[$class] = [$reflector, 'newInstanceWithoutConstructor'](...); + return self::$factories[$class] = $reflector->newInstanceWithoutConstructor(...); } public static function getClassReflector($class, $instantiableWithoutConstructor = false, $cloneable = null) diff --git a/LazyGhostTrait.php b/LazyGhostTrait.php index 2d90ca3..529ace2 100644 --- a/LazyGhostTrait.php +++ b/LazyGhostTrait.php @@ -17,6 +17,13 @@ use Symfony\Component\VarExporter\Internal\LazyObjectState; use Symfony\Component\VarExporter\Internal\LazyObjectTrait; +if (\PHP_VERSION_ID >= 80400) { + trigger_deprecation('symfony/var-exporter', '7.3', 'The "%s" trait is deprecated, use native lazy objects instead.', LazyGhostTrait::class); +} + +/** + * @deprecated since Symfony 7.3, use native lazy objects instead + */ trait LazyGhostTrait { use LazyObjectTrait; diff --git a/LazyProxyTrait.php b/LazyProxyTrait.php index 3c59b73..fc28c1d 100644 --- a/LazyProxyTrait.php +++ b/LazyProxyTrait.php @@ -18,6 +18,13 @@ use Symfony\Component\VarExporter\Internal\LazyObjectState; use Symfony\Component\VarExporter\Internal\LazyObjectTrait; +if (\PHP_VERSION_ID >= 80400) { + trigger_deprecation('symfony/var-exporter', '7.3', 'The "%s" trait is deprecated, use native lazy objects instead.', LazyProxyTrait::class); +} + +/** + * @deprecated since Symfony 7.3, use native lazy objects instead + */ trait LazyProxyTrait { use LazyObjectTrait; @@ -38,11 +45,12 @@ public static function createLazyProxy(\Closure $initializer, ?object $instance Registry::$classReflectors[$class] ??= new \ReflectionClass($class); $instance ??= Registry::$classReflectors[$class]->newInstanceWithoutConstructor(); Registry::$defaultProperties[$class] ??= (array) $instance; - Registry::$classResetters[$class] ??= Registry::getClassResetters($class); if (self::class === $class && \defined($class.'::LAZY_OBJECT_PROPERTY_SCOPES')) { Hydrator::$propertyScopes[$class] ??= $class::LAZY_OBJECT_PROPERTY_SCOPES; } + + Registry::$classResetters[$class] ??= Registry::getClassResetters($class); } else { $instance ??= Registry::$classReflectors[$class]->newInstanceWithoutConstructor(); } @@ -290,10 +298,6 @@ public function __clone(): void } $this->lazyObjectState = clone $this->lazyObjectState; - - if (isset($this->lazyObjectState->realInstance)) { - $this->lazyObjectState->realInstance = clone $this->lazyObjectState->realInstance; - } } public function __serialize(): array diff --git a/ProxyHelper.php b/ProxyHelper.php index c0534ea..b815e70 100644 --- a/ProxyHelper.php +++ b/ProxyHelper.php @@ -13,6 +13,7 @@ use Symfony\Component\VarExporter\Exception\LogicException; use Symfony\Component\VarExporter\Internal\Hydrator; +use Symfony\Component\VarExporter\Internal\LazyDecoratorTrait; use Symfony\Component\VarExporter\Internal\LazyObjectRegistry; /** @@ -23,10 +24,15 @@ final class ProxyHelper /** * Helps generate lazy-loading ghost objects. * + * @deprecated since Symfony 7.3, use native lazy objects instead + * * @throws LogicException When the class is incompatible with ghost objects */ public static function generateLazyGhost(\ReflectionClass $class): string { + if (\PHP_VERSION_ID >= 80400) { + trigger_deprecation('symfony/var-exporter', '7.3', 'Using ProxyHelper::generateLazyGhost() is deprecated, use native lazy objects instead.'); + } if (\PHP_VERSION_ID < 80300 && $class->isReadOnly()) { throw new LogicException(\sprintf('Cannot generate lazy ghost with PHP < 8.3: class "%s" is readonly.', $class->name)); } @@ -70,7 +76,7 @@ public static function generateLazyGhost(\ReflectionClass $class): string } if ($flags & (\ReflectionProperty::IS_FINAL | \ReflectionProperty::IS_PRIVATE)) { - throw new LogicException(sprintf('Cannot generate lazy ghost: property "%s::$%s" is final or private(set).', $class->name, $name)); + throw new LogicException(\sprintf('Cannot generate lazy ghost: property "%s::$%s" is final or private(set).', $class->name, $name)); } $p = $propertyScopes[$k][4] ?? Hydrator::$propertyScopes[$class->name][$k][4] = new \ReflectionProperty($scope, $name); @@ -92,11 +98,11 @@ public static function generateLazyGhost(\ReflectionClass $class): string $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)); + throw new LogicException(\sprintf('Cannot generate lazy ghost: hook "%s::%s()" is not supported.', $class->name, $method->name)); } } - $hooks .= " }\n"; + $hooks .= " }\n"; } $propertyScopes = self::exportPropertyScopes($class->name, $propertyScopes); @@ -118,7 +124,7 @@ class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectState::class); } /** - * Helps generate lazy-loading virtual proxies. + * Helps generate lazy-loading decorators. * * @param \ReflectionClass[] $interfaces * @@ -132,39 +138,49 @@ public static function generateLazyProxy(?\ReflectionClass $class, array $interf if ($class?->isFinal()) { throw new LogicException(\sprintf('Cannot generate lazy proxy: class "%s" is final.', $class->name)); } - if (\PHP_VERSION_ID < 80300 && $class?->isReadOnly()) { - throw new LogicException(\sprintf('Cannot generate lazy proxy with PHP < 8.3: class "%s" is readonly.', $class->name)); + if (\PHP_VERSION_ID < 80400) { + return self::generateLegacyLazyProxy($class, $interfaces); + } + + if ($class && !$class->isAbstract()) { + $parent = $class; + do { + $extendsInternalClass = $parent->isInternal(); + } while (!$extendsInternalClass && $parent = $parent->getParentClass()); + + if (!$extendsInternalClass) { + trigger_deprecation('symfony/var-exporter', '7.3', 'Generating lazy proxy for class "%s" is deprecated; leverage native lazy objects instead.', $class->name); + // throw new LogicException(\sprintf('Cannot generate lazy proxy: leverage native lazy objects instead for class "%s".', $class->name)); + } } $propertyScopes = $class ? Hydrator::$propertyScopes[$class->name] ??= Hydrator::getPropertyScopes($class->name) : []; $abstractProperties = []; $hookedProperties = []; - if (\PHP_VERSION_ID >= 80400 && $class) { - foreach ($propertyScopes as $key => [$scope, $name, , $access]) { - $propertyScopes[$k = "\0$scope\0$name"] ?? $propertyScopes[$k = "\0*\0$name"] ?? $k = $name; - $flags = $access >> 2; - - if ($k !== $key) { - continue; - } + foreach ($propertyScopes as $key => [$scope, $name, , $access]) { + $propertyScopes[$k = "\0$scope\0$name"] ?? $propertyScopes[$k = "\0*\0$name"] ?? $k = $name; + $flags = $access >> 2; - if ($flags & \ReflectionProperty::IS_ABSTRACT) { - $abstractProperties[$name] = $propertyScopes[$k][4] ?? Hydrator::$propertyScopes[$class->name][$k][4] = new \ReflectionProperty($scope, $name); - continue; - } - $abstractProperties[$name] = false; + if ($k !== $key || $flags & \ReflectionProperty::IS_PRIVATE) { + continue; + } - if (!($access & Hydrator::PROPERTY_HAS_HOOKS) || $flags & \ReflectionProperty::IS_VIRTUAL) { - continue; - } + if ($flags & \ReflectionProperty::IS_ABSTRACT) { + $abstractProperties[$name] = $propertyScopes[$k][4] ?? Hydrator::$propertyScopes[$class->name][$k][4] = new \ReflectionProperty($scope, $name); + continue; + } + $abstractProperties[$name] = false; - if ($flags & (\ReflectionProperty::IS_FINAL | \ReflectionProperty::IS_PRIVATE)) { - throw new LogicException(sprintf('Cannot generate lazy proxy: property "%s::$%s" is final or private(set).', $class->name, $name)); - } + if (!($access & Hydrator::PROPERTY_HAS_HOOKS)) { + continue; + } - $p = $propertyScopes[$k][4] ?? Hydrator::$propertyScopes[$class->name][$k][4] = new \ReflectionProperty($scope, $name); - $hookedProperties[$name] = [$p, $p->getHooks()]; + if ($flags & \ReflectionProperty::IS_FINAL) { + throw new LogicException(\sprintf('Cannot generate lazy proxy: property "%s::$%s" is final.', $class->name, $name)); } + + $p = $propertyScopes[$k][4] ?? Hydrator::$propertyScopes[$class->name][$k][4] = new \ReflectionProperty($scope, $name); + $hookedProperties[$name] = [$p, $p->getHooks()]; } $methodReflectors = [$class?->getMethods(\ReflectionMethod::IS_PUBLIC | \ReflectionMethod::IS_PROTECTED) ?? []]; @@ -174,12 +190,10 @@ public static function generateLazyProxy(?\ReflectionClass $class, array $interf } $methodReflectors[] = $interface->getMethods(); - if (\PHP_VERSION_ID >= 80400) { - foreach ($interface->getProperties() as $p) { - $abstractProperties[$p->name] ??= $p; - $hookedProperties[$p->name] ??= [$p, []]; - $hookedProperties[$p->name][1] += $p->getHooks(); - } + foreach ($interface->getProperties() as $p) { + $abstractProperties[$p->name] ??= $p; + $hookedProperties[$p->name] ??= [$p, []]; + $hookedProperties[$p->name][1] += $p->getHooks(); } } @@ -194,6 +208,9 @@ public static function generateLazyProxy(?\ReflectionClass $class, array $interf } foreach ($hookedProperties as $name => [$p, $methods]) { + if ($abstractProperties[$p->name] ?? false) { + continue; + } $type = self::exportType($p); $hooks .= "\n " .($p->isProtected() ? 'protected' : 'public') @@ -205,11 +222,7 @@ public static function generateLazyProxy(?\ReflectionClass $class, array $interf $ref = ($method->returnsReference() ? '&' : ''); $hooks .= <<lazyObjectState)) { - return (\$this->lazyObjectState->realInstance ??= (\$this->lazyObjectState->initializer)())->{$p->name}; - } - - return parent::\${$p->name}::get(); + return \$this->lazyObjectState->realInstance->{$p->name}; } EOPHP; @@ -218,23 +231,166 @@ public static function generateLazyProxy(?\ReflectionClass $class, array $interf $arg = '$'.$method->getParameters()[0]->name; $hooks .= <<lazyObjectState)) { - \$this->lazyObjectState->realInstance ??= (\$this->lazyObjectState->initializer)(); - \$this->lazyObjectState->realInstance->{$p->name} = {$arg}; - } - - parent::\${$p->name}::set({$arg}); + \$this->lazyObjectState->realInstance->{$p->name} = {$arg}; } EOPHP; } else { - throw new LogicException(sprintf('Cannot generate lazy proxy: hook "%s::%s()" is not supported.', $class->name, $method->name)); + throw new LogicException(\sprintf('Cannot generate lazy proxy: hook "%s::%s()" is not supported.', $class->name, $method->name)); } } $hooks .= " }\n"; } + $methods = []; + $methodReflectors = array_merge(...$methodReflectors); + + foreach ($methodReflectors as $method) { + if ('__get' !== strtolower($method->name) || 'mixed' === ($type = self::exportType($method) ?? 'mixed')) { + continue; + } + $trait = new \ReflectionMethod(LazyDecoratorTrait::class, '__get'); + $body = \array_slice(file($trait->getFileName()), $trait->getStartLine() - 1, $trait->getEndLine() - $trait->getStartLine()); + $body[0] = str_replace('): mixed', '): '.$type, $body[0]); + $methods['__get'] = strtr(implode('', $body).' }', [ + 'Hydrator' => '\\'.Hydrator::class, + 'Registry' => '\\'.LazyObjectRegistry::class, + ]); + break; + } + + foreach ($methodReflectors as $method) { + if (($method->isStatic() && !$method->isAbstract()) || isset($methods[$lcName = strtolower($method->name)])) { + continue; + } + if ($method->isFinal()) { + throw new LogicException(\sprintf('Cannot generate lazy proxy: method "%s::%s()" is final.', $class->name, $method->name)); + } + if (method_exists(LazyDecoratorTrait::class, $method->name)) { + continue; + } + + $signature = self::exportSignature($method, true, $args); + + if ($method->isStatic()) { + $body = " throw new \BadMethodCallException('Cannot forward abstract method \"{$method->class}::{$method->name}()\".');"; + } elseif (str_ends_with($signature, '): never') || str_ends_with($signature, '): void')) { + $body = <<lazyObjectState->realInstance->{$method->name}({$args}); + EOPHP; + } else { + $mayReturnThis = false; + foreach (preg_split('/[()|&]++/', self::exportType($method) ?? 'static') as $type) { + if (\in_array($type = ltrim($type, '?'), ['static', 'object'], true)) { + $mayReturnThis = true; + break; + } + foreach ([$class, ...$interfaces] as $r) { + if ($r && is_a($r->name, $type, true)) { + $mayReturnThis = true; + break 2; + } + } + } + + if ($method->returnsReference() || !$mayReturnThis) { + $body = <<lazyObjectState->realInstance->{$method->name}({$args}); + EOPHP; + } else { + $body = <<lazyObjectState->realInstance; + \${1} = \${0}->{$method->name}({$args}); + + return match (true) { + \${1} === \${0} => \$this, + !\${1} instanceof \${0} || !\${0} instanceof \${1} => \${1}, + null !== \$this->lazyObjectState->cloneInstance =& \${1} => clone \$this, + }; + EOPHP; + } + } + $methods[$lcName] = " {$signature}\n {\n{$body}\n }"; + } + + $types = $interfaces = array_unique(array_column($interfaces, 'name')); + $interfaces[] = LazyObjectInterface::class; + $interfaces = implode(', \\', $interfaces); + $parent = $class ? ' extends \\'.$class->name : ''; + array_unshift($types, $class ? 'parent' : ''); + $type = ltrim(implode('&\\', $types), '&'); + + if (!$class) { + $trait = new \ReflectionMethod(LazyDecoratorTrait::class, 'initializeLazyObject'); + $body = \array_slice(file($trait->getFileName()), $trait->getStartLine() - 1, $trait->getEndLine() - $trait->getStartLine()); + $body[0] = str_replace('): parent', '): '.$type, $body[0]); + $methods = ['initializeLazyObject' => implode('', $body).' }'] + $methods; + } + $body = $methods ? "\n".implode("\n\n", $methods)."\n" : ''; + $propertyScopes = $class ? self::exportPropertyScopes($class->name, $propertyScopes) : '[]'; + $lazyProxyTraitStatement = []; + + if ( + $class?->hasMethod('__unserialize') + && !$class->getMethod('__unserialize')->getParameters()[0]->getType() + ) { + // fix contravariance type problem when $class declares a `__unserialize()` method without typehint. + $lazyProxyTraitStatement[] = '__unserialize as private __doUnserialize;'; + + $body .= <<__doUnserialize(\$data); + } + + EOPHP; + } + + if ($lazyProxyTraitStatement) { + $lazyProxyTraitStatement = implode("\n ", $lazyProxyTraitStatement); + $lazyProxyTraitStatement = <<isReadOnly()) { + throw new LogicException(\sprintf('Cannot generate lazy proxy with PHP < 8.3: class "%s" is readonly.', $class->name)); + } + + $propertyScopes = $class ? Hydrator::$propertyScopes[$class->name] ??= Hydrator::getPropertyScopes($class->name) : []; + $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(); + } + $extendsInternalClass = false; if ($parent = $class) { do { @@ -360,7 +516,7 @@ public function __unserialize(\$data): void {$lazyProxyTraitStatement} private const LAZY_OBJECT_PROPERTY_SCOPES = {$propertyScopes}; - {$hooks}{$body}} + {$body}} // Help opcache.preload discover always-needed symbols class_exists(\Symfony\Component\VarExporter\Internal\Hydrator::class); diff --git a/README.md b/README.md index 7195270..92fbc69 100644 --- a/README.md +++ b/README.md @@ -57,65 +57,22 @@ Hydrator::hydrate($object, [], [ ]); ``` -`Lazy*Trait` +Lazy Proxies ------------ -The component provides two lazy-loading patterns: ghost objects and virtual -proxies (see https://martinfowler.com/eaaCatalog/lazyLoad.html for reference). +Since version 8.4, PHP provides support for lazy objects via the reflection API. +This native API works with concrete classes. It doesn't with abstracts nor with +internal ones. -Ghost objects work only with concrete and non-internal classes. In the generic -case, they are not compatible with using factories in their initializer. - -Virtual proxies work with concrete, abstract or internal classes. They provide an -API that looks like the actual objects and forward calls to them. They can cause -identity problems because proxies might not be seen as equivalents to the actual -objects they proxy. - -Because of this identity problem, ghost objects should be preferred when -possible. Exceptions thrown by the `ProxyHelper` class can help decide when it -can be used or not. - -Ghost objects and virtual proxies both provide implementations for the -`LazyObjectInterface` which allows resetting them to their initial state or to -forcibly initialize them when needed. Note that resetting a ghost object skips -its read-only properties. You should use a virtual proxy to reset read-only -properties. - -### `LazyGhostTrait` - -By using `LazyGhostTrait` either directly in your classes or by using -`ProxyHelper::generateLazyGhost()`, you can make their instances lazy-loadable. -This works by creating these instances empty and by computing their state only -when accessing a property. +This components provides helpers to generate lazy objects using the decorator +pattern, which works with abstract or internal classes and with interfaces: ```php -class FooLazyGhost extends Foo -{ - use LazyGhostTrait; -} - -$foo = FooLazyGhost::createLazyGhost(initializer: function (Foo $instance): void { - // [...] Use whatever heavy logic you need here - // to compute the $dependencies of the $instance - $instance->__construct(...$dependencies); - // [...] Call setters, etc. if needed -}); - -// $foo is now a lazy-loading ghost object. The initializer will -// be called only when and if a *property* is accessed. -``` - -### `LazyProxyTrait` - -Alternatively, `LazyProxyTrait` can be used to create virtual proxies: - -```php -$proxyCode = ProxyHelper::generateLazyProxy(new ReflectionClass(Foo::class)); -// $proxyCode contains the reference to LazyProxyTrait -// and should be dumped into a file in production envs +$proxyCode = ProxyHelper::generateLazyProxy(new ReflectionClass(AbstractFoo::class)); +// $proxyCode should be dumped into a file in production envs eval('class FooLazyProxy'.$proxyCode); -$foo = FooLazyProxy::createLazyProxy(initializer: function (): Foo { +$foo = FooLazyProxy::createLazyProxy(initializer: function (): AbstractFoo { // [...] Use whatever heavy logic you need here // to compute the $dependencies of the $instance $instance = new Foo(...$dependencies); @@ -123,10 +80,14 @@ $foo = FooLazyProxy::createLazyProxy(initializer: function (): Foo { return $instance; }); -// $foo is now a lazy-loading virtual proxy object. The initializer will +// $foo is now a lazy-loading decorator object. The initializer will // be called only when and if a *method* is called. ``` +In addition, this component provides traits and methods to aid in implementing +the ghost and proxy strategies in previous versions of PHP. Those are deprecated +when using PHP 8.4. + Resources --------- diff --git a/Tests/Fixtures/LazyGhost/RegularClass.php b/Tests/Fixtures/LazyGhost/RegularClass.php index 02bdce3..a81d57b 100644 --- a/Tests/Fixtures/LazyGhost/RegularClass.php +++ b/Tests/Fixtures/LazyGhost/RegularClass.php @@ -11,7 +11,7 @@ namespace Symfony\Component\VarExporter\Tests\Fixtures\LazyGhost; -class RegularClass +class RegularClass extends \stdClass { public function __construct( public int $foo, diff --git a/Tests/Fixtures/LazyProxy/AsymmetricVisibility.php b/Tests/Fixtures/LazyProxy/AsymmetricVisibility.php index a912ca4..57e8831 100644 --- a/Tests/Fixtures/LazyProxy/AsymmetricVisibility.php +++ b/Tests/Fixtures/LazyProxy/AsymmetricVisibility.php @@ -11,7 +11,7 @@ namespace Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy; -class AsymmetricVisibility +class AsymmetricVisibility extends \stdClass { public function __construct( public private(set) int $foo, diff --git a/Tests/Fixtures/LazyProxy/ConcreteReadOnlyClass.php b/Tests/Fixtures/LazyProxy/ConcreteReadOnlyClass.php new file mode 100644 index 0000000..c9ad34e --- /dev/null +++ b/Tests/Fixtures/LazyProxy/ConcreteReadOnlyClass.php @@ -0,0 +1,16 @@ + + * + * 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\LazyProxy; + +readonly class ConcreteReadOnlyClass extends ReadOnlyClass +{ +} diff --git a/Tests/Fixtures/LazyProxy/FinalPublicClass.php b/Tests/Fixtures/LazyProxy/FinalPublicClass.php index e61e2ee..acff3a9 100644 --- a/Tests/Fixtures/LazyProxy/FinalPublicClass.php +++ b/Tests/Fixtures/LazyProxy/FinalPublicClass.php @@ -11,7 +11,7 @@ namespace Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy; -class FinalPublicClass +class FinalPublicClass extends \stdClass { private $count = 0; diff --git a/Tests/Fixtures/LazyProxy/Hooked.php b/Tests/Fixtures/LazyProxy/Hooked.php index 62174f9..01ea194 100644 --- a/Tests/Fixtures/LazyProxy/Hooked.php +++ b/Tests/Fixtures/LazyProxy/Hooked.php @@ -11,7 +11,7 @@ namespace Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy; -class Hooked +class Hooked extends \stdClass { public int $notBacked { get { return 123; } diff --git a/Tests/Fixtures/LazyProxy/Php82NullStandaloneReturnType.php b/Tests/Fixtures/LazyProxy/Php82NullStandaloneReturnType.php index f01f573..dc8c6d0 100644 --- a/Tests/Fixtures/LazyProxy/Php82NullStandaloneReturnType.php +++ b/Tests/Fixtures/LazyProxy/Php82NullStandaloneReturnType.php @@ -11,7 +11,7 @@ namespace Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy; -class Php82NullStandaloneReturnType +class Php82NullStandaloneReturnType extends \stdClass { public function foo(): null { diff --git a/Tests/Fixtures/LazyProxy/ReadOnlyClass.php b/Tests/Fixtures/LazyProxy/ReadOnlyClass.php index e71616a..7448ef5 100644 --- a/Tests/Fixtures/LazyProxy/ReadOnlyClass.php +++ b/Tests/Fixtures/LazyProxy/ReadOnlyClass.php @@ -11,7 +11,7 @@ namespace Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy; -readonly class ReadOnlyClass +abstract readonly class ReadOnlyClass { public function __construct( public int $foo, diff --git a/Tests/Fixtures/LazyProxy/StringMagicGetClass.php b/Tests/Fixtures/LazyProxy/StringMagicGetClass.php index 9f79b0b..0ac52f8 100644 --- a/Tests/Fixtures/LazyProxy/StringMagicGetClass.php +++ b/Tests/Fixtures/LazyProxy/StringMagicGetClass.php @@ -11,7 +11,7 @@ namespace Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy; -class StringMagicGetClass +class StringMagicGetClass extends \stdClass { public function __get(string $name): string { diff --git a/Tests/Fixtures/LazyProxy/TestClass.php b/Tests/Fixtures/LazyProxy/TestClass.php index 0f12c6d..a5f6498 100644 --- a/Tests/Fixtures/LazyProxy/TestClass.php +++ b/Tests/Fixtures/LazyProxy/TestClass.php @@ -12,7 +12,7 @@ namespace Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy; #[\AllowDynamicProperties] -class TestClass +class TestClass extends \stdClass { public function __construct( protected \stdClass $dep, diff --git a/Tests/Fixtures/SimpleObject.php b/Tests/Fixtures/SimpleObject.php index 9187f65..8ee233b 100644 --- a/Tests/Fixtures/SimpleObject.php +++ b/Tests/Fixtures/SimpleObject.php @@ -11,7 +11,7 @@ namespace Symfony\Component\VarExporter\Tests\Fixtures; -class SimpleObject +class SimpleObject extends \stdClass { public function getMethod(): string { diff --git a/Tests/LazyProxyTraitTest.php b/Tests/LazyProxyTraitTest.php index 43213a7..9e0ab51 100644 --- a/Tests/LazyProxyTraitTest.php +++ b/Tests/LazyProxyTraitTest.php @@ -16,11 +16,11 @@ use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\VarExporter\Exception\LogicException; -use Symfony\Component\VarExporter\LazyProxyTrait; use Symfony\Component\VarExporter\ProxyHelper; use Symfony\Component\VarExporter\Tests\Fixtures\LazyGhost\RegularClass; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\AbstractHooked; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\AsymmetricVisibility; +use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\ConcreteReadOnlyClass; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\FinalPublicClass; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\Hooked; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\ReadOnlyClass; @@ -31,6 +31,9 @@ use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\TestWakeupClass; use Symfony\Component\VarExporter\Tests\Fixtures\SimpleObject; +/** + * @requires PHP 8.4 + */ class LazyProxyTraitTest extends TestCase { public function testGetter() @@ -88,15 +91,15 @@ public function testClone() }); $clone = clone $proxy; - $this->assertSame(0, $initCounter); + $this->assertSame(\PHP_VERSION_ID >= 80400 ? 1 : 0, $initCounter); $dep1 = $proxy->getDep(); - $this->assertSame(1, $initCounter); + $this->assertSame(\PHP_VERSION_ID >= 80400 ? 1 : 1, $initCounter); $dep2 = $clone->getDep(); - $this->assertSame(2, $initCounter); + $this->assertSame(\PHP_VERSION_ID >= 80400 ? 1 : 2, $initCounter); - $this->assertNotSame($dep1, $dep2); + $this->assertSame(\PHP_VERSION_ID >= 80400, $dep1 === $dep2); } public function testUnserialize() @@ -192,24 +195,19 @@ public function testStringMagicGet() public function testFinalPublicClass() { - $proxy = $this->createLazyProxy(FinalPublicClass::class, fn () => new FinalPublicClass()); - - $this->assertSame(1, $proxy->increment()); - $this->assertSame(2, $proxy->increment()); - $this->assertSame(1, $proxy->decrement()); + $this->expectException(LogicException::class, 'Cannot generate lazy proxy: method "Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\FinalPublicClass::increment()" is final.'); + $this->createLazyProxy(FinalPublicClass::class, fn () => new FinalPublicClass()); } public function testOverwritePropClass() { - $proxy = $this->createLazyProxy(TestOverwritePropClass::class, fn () => new TestOverwritePropClass('123', 5)); - - $this->assertSame('123', $proxy->getDep()); - $this->assertSame(1, $proxy->increment()); + $this->expectException(LogicException::class, 'Cannot generate lazy proxy: method "Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\FinalPublicClass::increment()" is final.'); + $this->createLazyProxy(TestOverwritePropClass::class, fn () => new TestOverwritePropClass('123', 5)); } public function testWither() { - $obj = new class { + $obj = new class extends \stdClass { public $foo = 123; public function withFoo($foo): static @@ -225,12 +223,12 @@ public function withFoo($foo): static $clone = $proxy->withFoo(234); $this->assertSame($clone::class, $proxy::class); $this->assertSame(234, $clone->foo); - $this->assertSame(234, $obj->foo); + $this->assertSame(\PHP_VERSION_ID >= 80400 ? 123 : 234, $obj->foo); } public function testFluent() { - $obj = new class { + $obj = new class extends \stdClass { public $foo = 123; public function setFoo($foo): static @@ -248,7 +246,7 @@ public function setFoo($foo): static public function testIndirectModification() { - $obj = new class { + $obj = new class extends \stdClass { public array $foo; }; $proxy = $this->createLazyProxy($obj::class, fn () => $obj); @@ -265,27 +263,11 @@ public function testReadOnlyClass() $this->expectExceptionMessage('Cannot generate lazy proxy with PHP < 8.3: class "Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\ReadOnlyClass" is readonly.'); } - $proxy = $this->createLazyProxy(ReadOnlyClass::class, fn () => new ReadOnlyClass(123)); + $proxy = $this->createLazyProxy(ReadOnlyClass::class, fn () => new ConcreteReadOnlyClass(123)); $this->assertSame(123, $proxy->foo); } - public function testLazyDecoratorClass() - { - $obj = new class extends TestClass { - use LazyProxyTrait { - createLazyProxy as private; - } - - public function __construct() - { - self::createLazyProxy(fn () => new TestClass((object) ['foo' => 123]), $this); - } - }; - - $this->assertSame(['foo' => 123], (array) $obj->getDep()); - } - public function testNormalization() { $object = $this->createLazyProxy(SimpleObject::class, fn () => new SimpleObject()); @@ -315,11 +297,11 @@ public function testReinitRegularLazyProxy() */ public function testReinitReadonlyLazyProxy() { - $object = $this->createLazyProxy(ReadOnlyClass::class, fn () => new ReadOnlyClass(123)); + $object = $this->createLazyProxy(ReadOnlyClass::class, fn () => new ConcreteReadOnlyClass(123)); $this->assertSame(123, $object->foo); - $object::createLazyProxy(fn () => new ReadOnlyClass(234), $object); + $object::createLazyProxy(fn () => new ConcreteReadOnlyClass(234), $object); $this->assertSame(234, $object->foo); } @@ -337,7 +319,7 @@ public function testConcretePropertyHooks() }); $this->assertSame(123, $object->notBacked); - $this->assertFalse($initialized); + $this->assertTrue($initialized); $this->assertSame(234, $object->backed); $this->assertTrue($initialized); @@ -407,6 +389,20 @@ public function testAsymmetricVisibility() $this->assertSame(123, $object->foo); } + public function testInternalClass() + { + $now = new \DateTimeImmutable(); + $initialized = false; + $object = $this->createLazyProxy(\DateTimeImmutable::class, function () use ($now, &$initialized) { + $initialized = true; + + return $now; + }); + + $this->assertSame(date('Y'), $object->format('Y')); + $this->assertTrue($initialized); + } + /** * @template T * @@ -414,7 +410,7 @@ public function testAsymmetricVisibility() * * @return T */ - private function createLazyProxy(string $class, \Closure $initializer): object + protected function createLazyProxy(string $class, \Closure $initializer): object { $r = new \ReflectionClass($class); diff --git a/Tests/LazyGhostTraitTest.php b/Tests/LegacyLazyGhostTraitTest.php similarity index 99% rename from Tests/LazyGhostTraitTest.php rename to Tests/LegacyLazyGhostTraitTest.php index 2b148a6..c650626 100644 --- a/Tests/LazyGhostTraitTest.php +++ b/Tests/LegacyLazyGhostTraitTest.php @@ -29,7 +29,10 @@ use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\HookedWithDefaultValue; use Symfony\Component\VarExporter\Tests\Fixtures\SimpleObject; -class LazyGhostTraitTest extends TestCase +/** + * @group legacy + */ +class LegacyLazyGhostTraitTest extends TestCase { public function testGetPublic() { diff --git a/Tests/LegacyLazyProxyTraitTest.php b/Tests/LegacyLazyProxyTraitTest.php new file mode 100644 index 0000000..383b08f --- /dev/null +++ b/Tests/LegacyLazyProxyTraitTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Tests; + +use Symfony\Component\VarExporter\LazyProxyTrait; +use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\FinalPublicClass; +use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\TestClass; +use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\TestOverwritePropClass; + +/** + * @requires PHP < 8.4 + * + * @group legacy + */ +class LegacyLazyProxyTraitTest extends LazyProxyTraitTest +{ + public function testLazyDecoratorClass() + { + $obj = new class extends TestClass { + use LazyProxyTrait { + createLazyProxy as private; + } + + public function __construct() + { + self::createLazyProxy(fn () => new TestClass((object) ['foo' => 123]), $this); + } + }; + + $this->assertSame(['foo' => 123], (array) $obj->getDep()); + } + + public function testFinalPublicClass() + { + $proxy = $this->createLazyProxy(FinalPublicClass::class, fn () => new FinalPublicClass()); + + $this->assertSame(1, $proxy->increment()); + $this->assertSame(2, $proxy->increment()); + $this->assertSame(1, $proxy->decrement()); + } + + public function testOverwritePropClass() + { + $proxy = $this->createLazyProxy(TestOverwritePropClass::class, fn () => new TestOverwritePropClass('123', 5)); + + $this->assertSame('123', $proxy->getDep()); + $this->assertSame(1, $proxy->increment()); + } +} diff --git a/Tests/LegacyProxyHelperTest.php b/Tests/LegacyProxyHelperTest.php new file mode 100644 index 0000000..71c46c4 --- /dev/null +++ b/Tests/LegacyProxyHelperTest.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Tests; + +use Symfony\Component\VarExporter\Exception\LogicException; +use Symfony\Component\VarExporter\ProxyHelper; +use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\Php82NullStandaloneReturnType; +use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\StringMagicGetClass; + +/** + * @requires PHP < 8.4 + * + * @group legacy + */ +class LegacyProxyHelperTest extends ProxyHelperTest +{ + public function testGenerateLazyProxy() + { + $expected = <<<'EOPHP' + extends \Symfony\Component\VarExporter\Tests\TestForProxyHelper implements \Symfony\Component\VarExporter\LazyObjectInterface + { + use \Symfony\Component\VarExporter\LazyProxyTrait; + + private const LAZY_OBJECT_PROPERTY_SCOPES = []; + + public function foo1(): ?\Symfony\Component\VarExporter\Tests\Bar + { + if (isset($this->lazyObjectState)) { + return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo1(...\func_get_args()); + } + + return parent::foo1(...\func_get_args()); + } + + public function foo4(\Symfony\Component\VarExporter\Tests\Bar|string $b, &$d): void + { + if (isset($this->lazyObjectState)) { + ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo4($b, $d, ...\array_slice(\func_get_args(), 2)); + } else { + parent::foo4($b, $d, ...\array_slice(\func_get_args(), 2)); + } + } + + protected function foo7() + { + if (isset($this->lazyObjectState)) { + return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo7(...\func_get_args()); + } + + return throw new \BadMethodCallException('Cannot forward abstract method "Symfony\Component\VarExporter\Tests\TestForProxyHelper::foo7()".'); + } + } + + // Help opcache.preload discover always-needed symbols + class_exists(\Symfony\Component\VarExporter\Internal\Hydrator::class); + class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectRegistry::class); + class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectState::class); + + EOPHP; + + $this->assertSame($expected, ProxyHelper::generateLazyProxy(new \ReflectionClass(TestForProxyHelper::class))); + } + + public function testGenerateLazyProxyForInterfaces() + { + $expected = <<<'EOPHP' + implements \Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface1, \Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface2, \Symfony\Component\VarExporter\LazyObjectInterface + { + use \Symfony\Component\VarExporter\LazyProxyTrait; + + private const LAZY_OBJECT_PROPERTY_SCOPES = []; + + public function initializeLazyObject(): \Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface1&\Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface2 + { + if ($state = $this->lazyObjectState ?? null) { + return $state->realInstance ??= ($state->initializer)(); + } + + return $this; + } + + public function foo1(): ?\Symfony\Component\VarExporter\Tests\Bar + { + if (isset($this->lazyObjectState)) { + return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo1(...\func_get_args()); + } + + return throw new \BadMethodCallException('Cannot forward abstract method "Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface1::foo1()".'); + } + + public function foo2(?\Symfony\Component\VarExporter\Tests\Bar $b, ...$d): \Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface2 + { + if (isset($this->lazyObjectState)) { + return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo2(...\func_get_args()); + } + + return throw new \BadMethodCallException('Cannot forward abstract method "Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface2::foo2()".'); + } + + public static function foo3(): string + { + throw new \BadMethodCallException('Cannot forward abstract method "Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface2::foo3()".'); + } + } + + // Help opcache.preload discover always-needed symbols + class_exists(\Symfony\Component\VarExporter\Internal\Hydrator::class); + class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectRegistry::class); + class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectState::class); + + EOPHP; + + $this->assertSame($expected, ProxyHelper::generateLazyProxy(null, [new \ReflectionClass(TestForProxyHelperInterface1::class), new \ReflectionClass(TestForProxyHelperInterface2::class)])); + } + + public static function classWithUnserializeMagicMethodProvider(): iterable + { + yield 'not type hinted __unserialize method' => [new class { + public function __unserialize($array): void + { + } + }, <<<'EOPHP' + implements \Symfony\Component\VarExporter\LazyObjectInterface + { + use \Symfony\Component\VarExporter\LazyProxyTrait { + __unserialize as private __doUnserialize; + } + + private const LAZY_OBJECT_PROPERTY_SCOPES = []; + + public function __unserialize($data): void + { + $this->__doUnserialize($data); + } + } + EOPHP]; + + yield 'type hinted __unserialize method' => [new class { + public function __unserialize(array $array): void + { + } + }, <<<'EOPHP' + implements \Symfony\Component\VarExporter\LazyObjectInterface + { + use \Symfony\Component\VarExporter\LazyProxyTrait; + + private const LAZY_OBJECT_PROPERTY_SCOPES = []; + } + EOPHP]; + } + + public function testAttributes() + { + $expected = <<<'EOPHP' + + public function foo(#[\SensitiveParameter] $a): int + { + if (isset($this->lazyObjectState)) { + return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo(...\func_get_args()); + } + + return parent::foo(...\func_get_args()); + } + } + + EOPHP; + + $class = new \ReflectionClass(new class { + #[SomeAttribute] + public function foo(#[\SensitiveParameter, AnotherAttribute] $a): int + { + } + }); + + $this->assertStringContainsString($expected, ProxyHelper::generateLazyProxy($class)); + } + + public function testCannotGenerateGhostForStringMagicGet() + { + $this->expectException(LogicException::class); + ProxyHelper::generateLazyGhost(new \ReflectionClass(StringMagicGetClass::class)); + } + + public function testNullStandaloneReturnType() + { + self::assertStringContainsString( + 'public function foo(): null', + ProxyHelper::generateLazyProxy(new \ReflectionClass(Php82NullStandaloneReturnType::class)) + ); + } +} diff --git a/Tests/ProxyHelperTest.php b/Tests/ProxyHelperTest.php index b9c67ad..ab396bc 100644 --- a/Tests/ProxyHelperTest.php +++ b/Tests/ProxyHelperTest.php @@ -12,12 +12,13 @@ namespace Symfony\Component\VarExporter\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\VarExporter\Exception\LogicException; use Symfony\Component\VarExporter\ProxyHelper; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\Hooked; use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\Php82NullStandaloneReturnType; -use Symfony\Component\VarExporter\Tests\Fixtures\LazyProxy\StringMagicGetClass; +/** + * @requires PHP 8.4 + */ class ProxyHelperTest extends TestCase { /** @@ -67,42 +68,94 @@ public function testGenerateLazyProxy() $expected = <<<'EOPHP' extends \Symfony\Component\VarExporter\Tests\TestForProxyHelper implements \Symfony\Component\VarExporter\LazyObjectInterface { - use \Symfony\Component\VarExporter\LazyProxyTrait; + use \Symfony\Component\VarExporter\Internal\LazyDecoratorTrait; private const LAZY_OBJECT_PROPERTY_SCOPES = []; public function foo1(): ?\Symfony\Component\VarExporter\Tests\Bar { - if (isset($this->lazyObjectState)) { - return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo1(...\func_get_args()); - } + return $this->lazyObjectState->realInstance->foo1(...\func_get_args()); + } - return parent::foo1(...\func_get_args()); + public function foo2(?\Symfony\Component\VarExporter\Tests\Bar $b, ...$d): ?\Symfony\Component\VarExporter\Tests\TestForProxyHelper + { + ${0} = $this->lazyObjectState->realInstance; + ${1} = ${0}->foo2(...\func_get_args()); + + return match (true) { + ${1} === ${0} => $this, + !${1} instanceof ${0} || !${0} instanceof ${1} => ${1}, + null !== $this->lazyObjectState->cloneInstance =& ${1} => clone $this, + }; + } + + public function &foo3(\Symfony\Component\VarExporter\Tests\Bar &$b, string &...$c) + { + return $this->lazyObjectState->realInstance->foo3($b, ...$c); } public function foo4(\Symfony\Component\VarExporter\Tests\Bar|string $b, &$d): void { - if (isset($this->lazyObjectState)) { - ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo4($b, $d, ...\array_slice(\func_get_args(), 2)); - } else { - parent::foo4($b, $d, ...\array_slice(\func_get_args(), 2)); - } + $this->lazyObjectState->realInstance->foo4($b, $d, ...\array_slice(\func_get_args(), 2)); + } + + public function foo5($b = new \stdClass([0 => 123]) . \Symfony\Component\VarExporter\Tests\Bar . \Symfony\Component\VarExporter\Tests\Bar::BAR . "a\0b") + { + ${0} = $this->lazyObjectState->realInstance; + ${1} = ${0}->foo5(...\func_get_args()); + + return match (true) { + ${1} === ${0} => $this, + !${1} instanceof ${0} || !${0} instanceof ${1} => ${1}, + null !== $this->lazyObjectState->cloneInstance =& ${1} => clone $this, + }; + } + + protected function foo6($b = null, $c = \PHP_EOL, $d = [\PHP_EOL], $e = [false, true, null]): never + { + $this->lazyObjectState->realInstance->foo6(...\func_get_args()); } protected function foo7() { - if (isset($this->lazyObjectState)) { - return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo7(...\func_get_args()); - } + ${0} = $this->lazyObjectState->realInstance; + ${1} = ${0}->foo7(...\func_get_args()); + + return match (true) { + ${1} === ${0} => $this, + !${1} instanceof ${0} || !${0} instanceof ${1} => ${1}, + null !== $this->lazyObjectState->cloneInstance =& ${1} => clone $this, + }; + } + + public function foo9($a = \Symfony\Component\VarExporter\Tests\TestForProxyHelper::BOB, $b = ['$a', "\$a\\n", "\$a\n"], $c = ['$a', "\$a\\n", "\$a\n", new \stdClass()]) + { + ${0} = $this->lazyObjectState->realInstance; + ${1} = ${0}->foo9(...\func_get_args()); + + return match (true) { + ${1} === ${0} => $this, + !${1} instanceof ${0} || !${0} instanceof ${1} => ${1}, + null !== $this->lazyObjectState->cloneInstance =& ${1} => clone $this, + }; + } - return throw new \BadMethodCallException('Cannot forward abstract method "Symfony\Component\VarExporter\Tests\TestForProxyHelper::foo7()".'); + public function foo10($a = [\M_PI, new \Symfony\Component\VarExporter\Tests\M_PI()]) + { + ${0} = $this->lazyObjectState->realInstance; + ${1} = ${0}->foo10(...\func_get_args()); + + return match (true) { + ${1} === ${0} => $this, + !${1} instanceof ${0} || !${0} instanceof ${1} => ${1}, + null !== $this->lazyObjectState->cloneInstance =& ${1} => clone $this, + }; } } // Help opcache.preload discover always-needed symbols class_exists(\Symfony\Component\VarExporter\Internal\Hydrator::class); class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectRegistry::class); - class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectState::class); EOPHP; @@ -114,35 +167,30 @@ public function testGenerateLazyProxyForInterfaces() $expected = <<<'EOPHP' implements \Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface1, \Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface2, \Symfony\Component\VarExporter\LazyObjectInterface { - use \Symfony\Component\VarExporter\LazyProxyTrait; + use \Symfony\Component\VarExporter\Internal\LazyDecoratorTrait; private const LAZY_OBJECT_PROPERTY_SCOPES = []; public function initializeLazyObject(): \Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface1&\Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface2 { - if ($state = $this->lazyObjectState ?? null) { - return $state->realInstance ??= ($state->initializer)(); - } - - return $this; + return $this->lazyObjectState->realInstance; } public function foo1(): ?\Symfony\Component\VarExporter\Tests\Bar { - if (isset($this->lazyObjectState)) { - return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo1(...\func_get_args()); - } - - return throw new \BadMethodCallException('Cannot forward abstract method "Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface1::foo1()".'); + return $this->lazyObjectState->realInstance->foo1(...\func_get_args()); } public function foo2(?\Symfony\Component\VarExporter\Tests\Bar $b, ...$d): \Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface2 { - if (isset($this->lazyObjectState)) { - return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo2(...\func_get_args()); - } - - return throw new \BadMethodCallException('Cannot forward abstract method "Symfony\Component\VarExporter\Tests\TestForProxyHelperInterface2::foo2()".'); + ${0} = $this->lazyObjectState->realInstance; + ${1} = ${0}->foo2(...\func_get_args()); + + return match (true) { + ${1} === ${0} => $this, + !${1} instanceof ${0} || !${0} instanceof ${1} => ${1}, + null !== $this->lazyObjectState->cloneInstance =& ${1} => clone $this, + }; } public static function foo3(): string @@ -154,7 +202,6 @@ public static function foo3(): string // Help opcache.preload discover always-needed symbols class_exists(\Symfony\Component\VarExporter\Internal\Hydrator::class); class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectRegistry::class); - class_exists(\Symfony\Component\VarExporter\Internal\LazyObjectState::class); EOPHP; @@ -171,14 +218,14 @@ public function testGenerateLazyProxyForClassWithUnserializeMagicMethod(object $ public static function classWithUnserializeMagicMethodProvider(): iterable { - yield 'not type hinted __unserialize method' => [new class { - public function __unserialize($array) + yield 'not type hinted __unserialize method' => [new class extends \stdClass { + public function __unserialize($array): void { } }, <<<'EOPHP' implements \Symfony\Component\VarExporter\LazyObjectInterface { - use \Symfony\Component\VarExporter\LazyProxyTrait { + use \Symfony\Component\VarExporter\Internal\LazyDecoratorTrait { __unserialize as private __doUnserialize; } @@ -191,14 +238,14 @@ public function __unserialize($data): void } EOPHP]; - yield 'type hinted __unserialize method' => [new class { - public function __unserialize(array $array) + yield 'type hinted __unserialize method' => [new class extends \stdClass { + public function __unserialize(array $array): void { } }, <<<'EOPHP' implements \Symfony\Component\VarExporter\LazyObjectInterface { - use \Symfony\Component\VarExporter\LazyProxyTrait; + use \Symfony\Component\VarExporter\Internal\LazyDecoratorTrait; private const LAZY_OBJECT_PROPERTY_SCOPES = []; } @@ -211,17 +258,13 @@ public function testAttributes() public function foo(#[\SensitiveParameter] $a): int { - if (isset($this->lazyObjectState)) { - return ($this->lazyObjectState->realInstance ??= ($this->lazyObjectState->initializer)())->foo(...\func_get_args()); - } - - return parent::foo(...\func_get_args()); + return $this->lazyObjectState->realInstance->foo(...\func_get_args()); } } EOPHP; - $class = new \ReflectionClass(new class { + $class = new \ReflectionClass(new class extends \stdClass { #[SomeAttribute] public function foo(#[\SensitiveParameter, AnotherAttribute] $a): int { @@ -231,15 +274,6 @@ public function foo(#[\SensitiveParameter, AnotherAttribute] $a): int $this->assertStringContainsString($expected, ProxyHelper::generateLazyProxy($class)); } - public function testCannotGenerateGhostForStringMagicGet() - { - $this->expectException(LogicException::class); - ProxyHelper::generateLazyGhost(new \ReflectionClass(StringMagicGetClass::class)); - } - - /** - * @requires PHP 8.2 - */ public function testNullStandaloneReturnType() { self::assertStringContainsString( @@ -254,8 +288,8 @@ public function testNullStandaloneReturnType() public function testPropertyHooks() { $proxyCode = ProxyHelper::generateLazyProxy(new \ReflectionClass(Hooked::class)); - self::assertStringContainsString("'backed' => [parent::class, 'backed', null, 7],", $proxyCode); - self::assertStringContainsString("'notBacked' => [parent::class, 'notBacked', null, 2055],", $proxyCode); + self::assertStringContainsString('public int $notBacked {', $proxyCode); + self::assertStringContainsString('public int $backed {', $proxyCode); } } diff --git a/composer.json b/composer.json index f3a227e..215d3ee 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ } ], "require": { - "php": ">=8.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { "symfony/property-access": "^6.4|^7.0",