diff --git a/.github/expected-missing-return-types.diff b/.github/expected-missing-return-types.diff index e0149f0b3c86e..af75e17c9c8ed 100644 --- a/.github/expected-missing-return-types.diff +++ b/.github/expected-missing-return-types.diff @@ -1087,6 +1087,17 @@ index f7ef22df5c..9439e9526f 100644 + public static function prepare($values, $objectsPool, &$refsPool, &$objectsCount, &$valuesAreStatic): array { $refs = $values; +diff --git a/src/Symfony/Component/VarExporter/Internal/GhostObjectState.php b/src/Symfony/Component/VarExporter/Internal/GhostObjectState.php +index 471c1a6b91..2e19d2ab2d 100644 +--- a/src/Symfony/Component/VarExporter/Internal/GhostObjectState.php ++++ b/src/Symfony/Component/VarExporter/Internal/GhostObjectState.php +@@ -54,5 +54,5 @@ class GhostObjectState + * @return bool Returns true when fully-initializing, false when partial-initializing + */ +- public function initialize($instance, $propertyName, $propertyScope) ++ public function initialize($instance, $propertyName, $propertyScope): bool + { + if (!$this->status) { diff --git a/src/Symfony/Component/Workflow/Event/Event.php b/src/Symfony/Component/Workflow/Event/Event.php index cd7fab7896..b340eba38e 100644 --- a/src/Symfony/Component/Workflow/Event/Event.php diff --git a/src/Symfony/Component/VarExporter/CHANGELOG.md b/src/Symfony/Component/VarExporter/CHANGELOG.md index 011ac96058cc9..25f2a55b5ff78 100644 --- a/src/Symfony/Component/VarExporter/CHANGELOG.md +++ b/src/Symfony/Component/VarExporter/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 6.2 --- + * Add `LazyGhostObjectTrait` * Add `Hydrator::hydrate()` * Preserve PHP references also when using `Hydrator::hydrate()` or `Instantiator::instantiate()` * Add support for hydrating from native (array) casts diff --git a/src/Symfony/Component/VarExporter/Instantiator.php b/src/Symfony/Component/VarExporter/Instantiator.php index 9a35b161bbd4f..10200c00bef2e 100644 --- a/src/Symfony/Component/VarExporter/Instantiator.php +++ b/src/Symfony/Component/VarExporter/Instantiator.php @@ -54,6 +54,6 @@ public static function instantiate(string $class, array $properties = [], array $instance = unserialize('O:'.\strlen($class).':"'.$class.'":0:{}'); } - return Hydrator::hydrate($instance, $properties, $scopedProperties); + return $properties || $scopedProperties ? Hydrator::hydrate($instance, $properties, $scopedProperties) : $instance; } } diff --git a/src/Symfony/Component/VarExporter/Internal/EmptyScope.php b/src/Symfony/Component/VarExporter/Internal/EmptyScope.php new file mode 100644 index 0000000000000..224f6b96452fc --- /dev/null +++ b/src/Symfony/Component/VarExporter/Internal/EmptyScope.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Internal; + +/** + * @internal + */ +enum EmptyScope +{ +} diff --git a/src/Symfony/Component/VarExporter/Internal/GhostObjectRegistry.php b/src/Symfony/Component/VarExporter/Internal/GhostObjectRegistry.php new file mode 100644 index 0000000000000..757ce1b7b604e --- /dev/null +++ b/src/Symfony/Component/VarExporter/Internal/GhostObjectRegistry.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter\Internal; + +/** + * Stores the state of lazy ghost objects and caches related reflection information. + * + * As a micro-optimization, this class uses no type declarations. + * + * @internal + */ +class GhostObjectRegistry +{ + /** + * @var array + */ + public static $states = []; + + /** + * @var array + */ + public static $classReflectors = []; + + /** + * @var array> + */ + public static $defaultProperties = []; + + /** + * @var array> + */ + public static $classResetters = []; + + /** + * @var array + */ + public static $classAccessors = []; + + /** + * @var array + */ + public static $parentMethods = []; + + public static function getClassResetters($class) + { + $classProperties = []; + $propertyScopes = Hydrator::$propertyScopes[$class] ??= Hydrator::getPropertyScopes($class); + + foreach ($propertyScopes as $key => [$scope, $name, $readonlyScope]) { + if ('lazyGhostObjectId' !== $name && null !== ($propertyScopes["\0$scope\0$name"] ?? $propertyScopes["\0*\0$name"] ?? $readonlyScope)) { + $classProperties[$readonlyScope ?? $scope][$name] = $key; + } + } + + $resetters = []; + foreach ($classProperties as $scope => $properties) { + $resetters[] = \Closure::bind(static function ($instance, $skippedProperties = []) use ($properties) { + foreach ($properties as $name => $key) { + if (!\array_key_exists($key, $skippedProperties)) { + unset($instance->$name); + } + } + }, null, $scope); + } + + $resetters[] = static function ($instance, $skippedProperties = []) { + foreach ((array) $instance as $name => $value) { + if ("\0" !== ($name[0] ?? '') && !\array_key_exists($name, $skippedProperties)) { + unset($instance->$name); + } + } + }; + + return $resetters; + } + + public static function getClassAccessors($class) + { + return \Closure::bind(static function () { + return [ + 'get' => static function &($instance, $name, $readonly) { + if (!$readonly) { + return $instance->$name; + } + $value = $instance->$name; + + return $value; + }, + 'set' => static function ($instance, $name, $value) { + $instance->$name = $value; + }, + 'isset' => static function ($instance, $name) { + return isset($instance->$name); + }, + 'unset' => static function ($instance, $name) { + unset($instance->$name); + }, + ]; + }, null, $class)(); + } + + public static function getParentMethods($class) + { + $parent = get_parent_class($class); + + return [ + 'get' => $parent && method_exists($parent, '__get') ? ((new \ReflectionMethod($parent, '__get'))->returnsReference() ? 2 : 1) : 0, + 'set' => $parent && method_exists($parent, '__set'), + 'isset' => $parent && method_exists($parent, '__isset'), + 'unset' => $parent && method_exists($parent, '__unset'), + 'clone' => $parent && method_exists($parent, '__clone'), + 'serialize' => $parent && method_exists($parent, '__serialize'), + 'sleep' => $parent && method_exists($parent, '__sleep'), + 'destruct' => $parent && method_exists($parent, '__destruct'), + ]; + } +} diff --git a/src/Symfony/Component/VarExporter/Internal/GhostObjectState.php b/src/Symfony/Component/VarExporter/Internal/GhostObjectState.php new file mode 100644 index 0000000000000..471c1a6b91381 --- /dev/null +++ b/src/Symfony/Component/VarExporter/Internal/GhostObjectState.php @@ -0,0 +1,87 @@ + + * + * 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\VarExporter\Hydrator; + +/** + * Keeps the state of lazy ghost objects. + * + * As a micro-optimization, this class uses no type declarations. + * + * @internal + */ +class GhostObjectState +{ + public const STATUS_INITIALIZED_PARTIAL = 1; + public const STATUS_UNINITIALIZED_FULL = 2; + public const STATUS_INITIALIZED_FULL = 3; + + public \Closure $initializer; + + /** + * @var array> + */ + public $preInitUnsetProperties; + + /** + * @var array + */ + public $preInitSetProperties = []; + + /** + * @var array> + */ + public $unsetProperties = []; + + /** + * One of self::STATUS_*. + * + * @var int + */ + public $status; + + /** + * @return bool Returns true when fully-initializing, false when partial-initializing + */ + public function initialize($instance, $propertyName, $propertyScope) + { + if (!$this->status) { + $this->status = 1 < (new \ReflectionFunction($this->initializer))->getNumberOfRequiredParameters() ? self::STATUS_INITIALIZED_PARTIAL : self::STATUS_UNINITIALIZED_FULL; + $this->preInitUnsetProperties ??= $this->unsetProperties; + } + + if (self::STATUS_INITIALIZED_FULL === $this->status) { + return true; + } + + if (self::STATUS_UNINITIALIZED_FULL === $this->status) { + if ($defaultProperties = array_diff_key(GhostObjectRegistry::$defaultProperties[\get_class($instance)], (array) $instance)) { + Hydrator::hydrate($instance, $defaultProperties); + } + + $this->status = self::STATUS_INITIALIZED_FULL; + ($this->initializer)($instance); + + return true; + } + + $value = ($this->initializer)(...[$instance, $propertyName, $propertyScope]); + + $propertyScope ??= \get_class($instance); + $accessor = GhostObjectRegistry::$classAccessors[$propertyScope] ??= GhostObjectRegistry::getClassAccessors($propertyScope); + + $accessor['set']($instance, $propertyName, $value); + + return false; + } +} diff --git a/src/Symfony/Component/VarExporter/LazyGhostObjectInterface.php b/src/Symfony/Component/VarExporter/LazyGhostObjectInterface.php new file mode 100644 index 0000000000000..361d2766b8b42 --- /dev/null +++ b/src/Symfony/Component/VarExporter/LazyGhostObjectInterface.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; + +interface LazyGhostObjectInterface +{ + /** + * Forces initialization of a lazy ghost object. + */ + public function initializeLazyGhostObject(): void; + + /** + * @return bool Returns false when the object cannot be reset, ie when it's not a ghost object + */ + public function resetLazyGhostObject(): bool; +} diff --git a/src/Symfony/Component/VarExporter/LazyGhostObjectTrait.php b/src/Symfony/Component/VarExporter/LazyGhostObjectTrait.php new file mode 100644 index 0000000000000..e7229554cf148 --- /dev/null +++ b/src/Symfony/Component/VarExporter/LazyGhostObjectTrait.php @@ -0,0 +1,348 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\VarExporter; + +use Symfony\Component\VarExporter\Internal\EmptyScope; +use Symfony\Component\VarExporter\Internal\GhostObjectRegistry as Registry; +use Symfony\Component\VarExporter\Internal\GhostObjectState; +use Symfony\Component\VarExporter\Internal\Hydrator; + +trait LazyGhostObjectTrait +{ + private int $lazyGhostObjectId = 0; + + /** + * @param \Closure(static):void|\Closure(static, string, ?string):mixed $initializer Initializes the instance passed as argument; when partial initialization + * is desired the closure should take extra arguments $propertyName and + * $propertyScope and should return the value of the corresponding property + */ + public static function createLazyGhostObject(\Closure $initializer): static + { + $class = static::class; + $instance = (Registry::$classReflectors[$class] ??= new \ReflectionClass($class))->newInstanceWithoutConstructor(); + + Registry::$defaultProperties[$class] ??= (array) $instance; + $instance->lazyGhostObjectId = $id = spl_object_id($instance); + $state = Registry::$states[$id] = new GhostObjectState(); + $state->initializer = $initializer; + + foreach (Registry::$classResetters[$class] ??= Registry::getClassResetters($class) as $reset) { + $reset($instance); + } + + return $instance; + } + + /** + * Forces initialization of a lazy ghost object. + */ + public function initializeLazyGhostObject(): void + { + if (!$state = Registry::$states[$this->lazyGhostObjectId] ?? null) { + return; + } + + $class = static::class; + $properties = (array) $this; + $propertyScopes = Hydrator::$propertyScopes[$class] ??= Hydrator::getPropertyScopes($class); + foreach ($propertyScopes as $key => [$scope, $name, $readonlyScope]) { + $propertyScopes[$k = "\0$scope\0$name"] ?? $propertyScopes[$k = "\0".($scope = '*')."\0$name"] ?? $k = $name; + + if ($k !== $key || \array_key_exists($k, $properties) || isset($state->unsetProperties[$scope][$name])) { + continue; + } + if ($state->initialize($this, $name, $readonlyScope ?? ('*' !== $scope ? $scope : null))) { + return; + } + $properties = (array) $this; + } + } + + /** + * @return bool Returns false when the object cannot be reset, ie when it's not a ghost object + */ + public function resetLazyGhostObject(): bool + { + if (!$state = Registry::$states[$this->lazyGhostObjectId] ?? null) { + return false; + } + + if (!$state->status) { + $state->preInitSetProperties = []; + $state->preInitUnsetProperties ??= $state->unsetProperties ?? []; + } + + $class = static::class; + $propertyScopes = Hydrator::$propertyScopes[$class] ??= Hydrator::getPropertyScopes($class); + $skippedProperties = $state->preInitSetProperties; + foreach ($propertyScopes as $key => [$scope, $name, $readonlyScope]) { + $propertyScopes[$k = "\0$scope\0$name"] ?? $propertyScopes[$k = "\0*\0$name"] ?? $k = $name; + + if (null !== $readonlyScope && $k === $key) { + $skippedProperties[$key] = true; + } + } + + foreach (Registry::$classResetters[$class] as $reset) { + $reset($this, $skippedProperties); + } + + if (GhostObjectState::STATUS_INITIALIZED_FULL === $state->status) { + $state->status = GhostObjectState::STATUS_UNINITIALIZED_FULL; + } + + $state->unsetProperties = $state->preInitUnsetProperties; + + return true; + } + + public function &__get($name) + { + $propertyScopes = Hydrator::$propertyScopes[static::class] ??= Hydrator::getPropertyScopes(static::class); + $scope = null; + + if ([$class, , $readonlyScope] = $propertyScopes[$name] ?? null) { + if (isset($propertyScopes["\0$class\0$name"]) || isset($propertyScopes["\0*\0$name"])) { + $scope = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['class'] ?? EmptyScope::class; + + if (isset($propertyScopes["\0*\0$name"]) && ($class === $scope || is_subclass_of($class, $scope))) { + $scope = null; + } + } + + if ($state = Registry::$states[$this->lazyGhostObjectId] ?? null) { + if (isset($state->unsetProperties[$scope ?? '*'][$name])) { + $class = null; + } elseif (null === $scope || isset($propertyScopes["\0$scope\0$name"])) { + $state->initialize($this, $name, $readonlyScope ?? $scope); + goto get_in_scope; + } + } + } + + if ($parent = (Registry::$parentMethods[self::class] ??= Registry::getParentMethods(self::class))['get']) { + if (2 === $parent) { + $value = &parent::__get($name); + } else { + $value = parent::__get($name); + } + + return $value; + } + + if (null === $class) { + $frame = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 1)[0]; + trigger_error(sprintf('Undefined property: %s::$%s in %s on line %s', static::class, $name, $frame['file'], $frame['line']), \E_USER_NOTICE); + } + + get_in_scope: + + if (null === $scope) { + if (null === $readonlyScope) { + return $this->$name; + } + $value = $this->$name; + + return $value; + } + $accessor = Registry::$classAccessors[$scope] ??= Registry::getClassAccessors($scope); + + return $accessor['get']($this, $name, null !== $readonlyScope); + } + + public function __set($name, $value): void + { + $propertyScopes = Hydrator::$propertyScopes[static::class] ??= Hydrator::getPropertyScopes(static::class); + $scope = null; + $id = $this->lazyGhostObjectId; + + if ([$class, , $readonlyScope] = $propertyScopes[$name] ?? null) { + if (null !== $readonlyScope || isset($propertyScopes["\0$class\0$name"]) || isset($propertyScopes["\0*\0$name"])) { + $scope = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['class'] ?? EmptyScope::class; + + if (null === $readonlyScope && isset($propertyScopes["\0*\0$name"]) && ($class === $scope || is_subclass_of($class, $scope))) { + $scope = null; + } + } + + $state = Registry::$states[$this->lazyGhostObjectId] ?? null; + if ($state && ($readonlyScope === $scope || isset($propertyScopes["\0$scope\0$name"]))) { + if (!$state->status && null === $state->preInitUnsetProperties) { + $propertyScopes[$k = "\0$class\0$name"] ?? $propertyScopes[$k = "\0*\0$name"] ?? $k = $name; + $state->preInitSetProperties[$k] = true; + } + + goto set_in_scope; + } + } + + if ((Registry::$parentMethods[self::class] ??= Registry::getParentMethods(self::class))['set']) { + parent::__set($name, $value); + + return; + } + + set_in_scope: + + if (null === $scope) { + $this->$name = $value; + unset($state->unsetProperties['*'][$name]); + + return; + } + $accessor = Registry::$classAccessors[$scope] ??= Registry::getClassAccessors($scope); + + $accessor['set']($this, $name, $value); + unset($state->unsetProperties[$scope][$name]); + } + + public function __isset($name): bool + { + $propertyScopes = Hydrator::$propertyScopes[static::class] ??= Hydrator::getPropertyScopes(static::class); + $scope = null; + + if ([$class, , $readonlyScope] = $propertyScopes[$name] ?? null) { + if (isset($propertyScopes["\0$class\0$name"]) || isset($propertyScopes["\0*\0$name"])) { + $scope = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['class'] ?? EmptyScope::class; + + if (isset($propertyScopes["\0*\0$name"]) && ($class === $scope || is_subclass_of($class, $scope))) { + $scope = null; + } + } + + if ($state = Registry::$states[$this->lazyGhostObjectId] ?? null) { + if (isset($state->unsetProperties[$scope ?? '*'][$name])) { + return false; + } + + if (null === $scope || isset($propertyScopes["\0$scope\0$name"])) { + $state->initialize($this, $name, $readonlyScope ?? $scope); + goto isset_in_scope; + } + } + } + + if ((Registry::$parentMethods[self::class] ??= Registry::getParentMethods(self::class))['isset']) { + return parent::__isset($name); + } + + isset_in_scope: + + if (null === $scope) { + return isset($this->$name); + } + $accessor = Registry::$classAccessors[$scope] ??= Registry::getClassAccessors($scope); + + return $accessor['isset']($this, $name); + } + + public function __unset($name): void + { + $propertyScopes = Hydrator::$propertyScopes[static::class] ??= Hydrator::getPropertyScopes(static::class); + $scope = null; + $id = $this->lazyGhostObjectId; + + if ([$class, , $readonlyScope] = $propertyScopes[$name] ?? null) { + if (null !== $readonlyScope || isset($propertyScopes["\0$class\0$name"]) || isset($propertyScopes["\0*\0$name"])) { + $scope = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['class'] ?? EmptyScope::class; + + if (null === $readonlyScope && isset($propertyScopes["\0*\0$name"]) && ($class === $scope || is_subclass_of($class, $scope))) { + $scope = null; + } + } + + $state = Registry::$states[$this->lazyGhostObjectId] ?? null; + if ($state && ($readonlyScope === $scope || isset($propertyScopes["\0$scope\0$name"]))) { + if (!$state->status && null === $state->preInitUnsetProperties) { + $propertyScopes[$k = "\0$class\0$name"] ?? $propertyScopes[$k = "\0*\0$name"] ?? $k = $name; + unset($state->preInitSetProperties[$k]); + } + $state->unsetProperties[$scope ?? '*'][$name] = true; + + return; + } + } + + if ((Registry::$parentMethods[self::class] ??= Registry::getParentMethods(self::class))['unset']) { + parent::__unset($name); + + return; + } + + if (null === $scope) { + unset($this->$name); + + return; + } + $accessor = Registry::$classAccessors[$scope] ??= Registry::getClassAccessors($scope); + + $accessor['unset']($this, $name); + } + + public function __clone() + { + if ($previousId = $this->lazyGhostObjectId) { + $this->lazyGhostObjectId = $id = spl_object_id($this); + Registry::$states[$id] = clone Registry::$states[$previousId]; + } + + if ((Registry::$parentMethods[self::class] ??= Registry::getParentMethods(self::class))['clone']) { + parent::__clone(); + } + } + + public function __serialize(): array + { + $class = self::class; + + if ((Registry::$parentMethods[$class] ??= Registry::getParentMethods($class))['serialize']) { + return parent::__serialize(); + } + + $this->initializeLazyGhostObject(); + $properties = (array) $this; + unset($properties["\0$class\0lazyGhostObjectId"]); + + if (!Registry::$parentMethods[$class]['sleep']) { + return $properties; + } + + $scope = get_parent_class($class); + $data = []; + + foreach (parent::__sleep() as $name) { + $value = $properties[$k = $name] ?? $properties[$k = "\0*\0$name"] ?? $properties[$k = "\0$scope\0$name"] ?? $k = null; + + if (null === $k) { + trigger_error(sprintf('serialize(): "%s" returned as member variable from __sleep() but does not exist', $name), \E_USER_NOTICE); + } else { + $data[$k] = $value; + } + } + + return $data; + } + + public function __destruct() + { + $state = Registry::$states[$this->lazyGhostObjectId] ?? null; + + try { + if ($state?->status && (Registry::$parentMethods[self::class] ??= Registry::getParentMethods(self::class))['destruct']) { + parent::__destruct(); + } + } finally { + unset(Registry::$states[$this->lazyGhostObjectId]); + $this->lazyGhostObjectId = 0; + } + } +} diff --git a/src/Symfony/Component/VarExporter/README.md b/src/Symfony/Component/VarExporter/README.md index a34e4c23d725b..a2e2a9050f1cb 100644 --- a/src/Symfony/Component/VarExporter/README.md +++ b/src/Symfony/Component/VarExporter/README.md @@ -1,15 +1,22 @@ VarExporter Component ===================== -The VarExporter component allows exporting any serializable PHP data structure to -plain PHP code. While doing so, it preserves all the semantics associated with -the serialization mechanism of PHP (`__wakeup`, `__sleep`, `Serializable`, -`__serialize`, `__unserialize`). +The VarExporter component provides various tools to deal with the internal state +of objects: -It also provides an instantiator that allows creating and populating objects -without calling their constructor nor any other methods. +- `VarExporter::export()` allows exporting any serializable PHP data structure to + plain PHP code. While doing so, it preserves all the semantics associated with + the serialization mechanism of PHP (`__wakeup`, `__sleep`, `Serializable`, + `__serialize`, `__unserialize`.) +- `Instantiator::instantiate()` creates an object and sets its properties without + calling its constructor nor any other methods. +- `Hydrator::hydrate()` can set the properties of an existing object. +- `LazyGhostObjectTrait` can make a class behave as a lazy loading ghost object. -The reason to use this component *vs* `serialize()` or +VarExporter::export() +--------------------- + +The reason to use `VarExporter::export()` *vs* `serialize()` or [igbinary](https://github.com/igbinary/igbinary) is performance: thanks to OPcache, the resulting code is significantly faster and more memory efficient than using `unserialize()` or `igbinary_unserialize()`. @@ -20,14 +27,75 @@ It also provides a few improvements over `var_export()`/`serialize()`: * the output is PSR-2 compatible; * the output can be re-indented without messing up with `\r` or `\n` in the data - * missing classes throw a `ClassNotFoundException` instead of being unserialized to - `PHP_Incomplete_Class` objects; + * missing classes throw a `ClassNotFoundException` instead of being unserialized + to `PHP_Incomplete_Class` objects; * references involving `SplObjectStorage`, `ArrayObject` or `ArrayIterator` instances are preserved; * `Reflection*`, `IteratorIterator` and `RecursiveIteratorIterator` classes throw an exception when being serialized (their unserialized version is broken anyway, see https://bugs.php.net/76737). +Instantiator and Hydrator +------------------------- + +`Instantiator::instantiate($class)` creates an object of the given class without +calling its constructor nor any other methods. + +`Hydrator::hydrate()` sets the properties of an existing object, including +private and protected ones. For example: + +```php +// Sets the public or protected $object->propertyName property +Hydrator::hydrate($object, ['propertyName' => $propertyValue]); + +// Sets a private property defined on its parent Bar class: +Hydrator::hydrate($object, ["\0Bar\0privateBarProperty" => $propertyValue]); + +// Alternative way to set the private $object->privateBarProperty property +Hydrator::hydrate($object, [], [ + Bar::class => ['privateBarProperty' => $propertyValue], +]); +``` + +LazyGhostObjectTrait +-------------------- + +By using `LazyGhostObjectTrait` either directly in your classes or using +inheritance, you can make their instances able to lazy load themselves. This +works by creating these instances empty and by computing their state only when +accessing a property. + +```php +FooMadeLazy extends Foo +{ + use LazyGhostObjectTrait; +} + +// This closure will be called when the object needs to be initialized, ie when a property is accessed +$initializer = function (Foo $instance) { + // [...] Use whatever heavy logic you need here to compute the $dependencies of the $instance + $instance->__construct(...$dependencies); +}; + +$foo = FooMadeLazy::createLazyGhostObject($initializer); +``` + +You can also partially initialize the objects on a property-by-property basis by +adding two arguments to the initializer: + +```php +$initializer = function (Foo $instance, string $propertyName, ?string $propertyScope) { + if (Foo::class === $propertyScope && 'bar' === $propertyName) { + return 123; + } + // [...] Add more logic for the other properties +}; +``` + +Because lazy-initialization is not triggered when (un)setting a property, it's +also possible to do partial initialization by calling setters on a just-created +ghost object. + Resources --------- diff --git a/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/ChildMagicClass.php b/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/ChildMagicClass.php new file mode 100644 index 0000000000000..b9926ab78e93a --- /dev/null +++ b/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/ChildMagicClass.php @@ -0,0 +1,22 @@ + + * + * 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\LazyGhostObject; + +use Symfony\Component\VarExporter\LazyGhostObjectInterface; +use Symfony\Component\VarExporter\LazyGhostObjectTrait; + +class ChildMagicClass extends MagicClass implements LazyGhostObjectInterface +{ + use LazyGhostObjectTrait; + + private int $data = 123; +} diff --git a/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/ChildTestClass.php b/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/ChildTestClass.php new file mode 100644 index 0000000000000..f971ae452f29e --- /dev/null +++ b/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/ChildTestClass.php @@ -0,0 +1,43 @@ + + * + * 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\LazyGhostObject; + +use Symfony\Component\VarExporter\LazyGhostObjectInterface; + +class ChildTestClass extends TestClass implements LazyGhostObjectInterface +{ + public int $public = 4; + public readonly int $publicReadonly; + protected int $protected = 5; + protected readonly int $protectedReadonly; + private int $private = 6; + + public function __construct() + { + if (6 !== $this->private) { + throw new \LogicException('Bad value for TestClass::$private'); + } + + $this->publicReadonly = 4; + $this->protectedReadonly = 5; + + parent::__construct(); + + if (-2 !== $this->protected) { + throw new \LogicException('Bad value for TestClass::$protected'); + } + + $this->public = -4; + $this->protected = -5; + $this->private = -6; + } +} diff --git a/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/MagicClass.php b/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/MagicClass.php new file mode 100644 index 0000000000000..204ccc09242a7 --- /dev/null +++ b/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/MagicClass.php @@ -0,0 +1,59 @@ + + * + * 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\LazyGhostObject; + +class MagicClass +{ + public static int $destructCounter = 0; + public int $cloneCounter = 0; + private array $data = []; + + public function __construct() + { + $this->data['foo'] = 'bar'; + } + + public function __get($name) + { + return $this->data[$name] ?? null; + } + + public function __set($name, $value) + { + $this->data[$name] = $value; + } + + public function __isset($name): bool + { + return isset($this->data[$name]); + } + + public function __unset($name) + { + unset($this->data[$name]); + } + + public function __clone() + { + ++$this->cloneCounter; + } + + public function __sleep(): array + { + return ['data']; + } + + public function __destruct() + { + ++self::$destructCounter; + } +} diff --git a/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/NoMagicClass.php b/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/NoMagicClass.php new file mode 100644 index 0000000000000..75cea5a6dfb57 --- /dev/null +++ b/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/NoMagicClass.php @@ -0,0 +1,35 @@ + + * + * 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\LazyGhostObject; + +class NoMagicClass +{ + public function __get($name) + { + throw new \BadMethodCallException(__FUNCTION__."({$name})"); + } + + public function __set($name, $value) + { + throw new \BadMethodCallException(__FUNCTION__."({$name})"); + } + + public function __isset($name): bool + { + throw new \BadMethodCallException(__FUNCTION__."({$name})"); + } + + public function __unset($name) + { + throw new \BadMethodCallException(__FUNCTION__."({$name})"); + } +} diff --git a/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/TestClass.php b/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/TestClass.php new file mode 100644 index 0000000000000..ecb4d4236f5a2 --- /dev/null +++ b/src/Symfony/Component/VarExporter/Tests/Fixtures/LazyGhostObject/TestClass.php @@ -0,0 +1,32 @@ + + * + * 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\LazyGhostObject; + +use Symfony\Component\VarExporter\LazyGhostObjectTrait; + +class TestClass extends NoMagicClass +{ + use LazyGhostObjectTrait; + + public int $public = 1; + protected int $protected = 2; + protected readonly int $protectedReadonly; + private int $private = 3; + + public function __construct() + { + $this->public = -1; + $this->protected = -2; + $this->protectedReadonly ??= 2; + $this->private = -3; + } +} diff --git a/src/Symfony/Component/VarExporter/Tests/LazyGhostObjectTraitTest.php b/src/Symfony/Component/VarExporter/Tests/LazyGhostObjectTraitTest.php new file mode 100644 index 0000000000000..a44c1b7be6810 --- /dev/null +++ b/src/Symfony/Component/VarExporter/Tests/LazyGhostObjectTraitTest.php @@ -0,0 +1,243 @@ + + * + * 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 PHPUnit\Framework\TestCase; +use Symfony\Component\VarExporter\Internal\GhostObjectRegistry; +use Symfony\Component\VarExporter\Tests\Fixtures\LazyGhostObject\ChildMagicClass; +use Symfony\Component\VarExporter\Tests\Fixtures\LazyGhostObject\ChildTestClass; +use Symfony\Component\VarExporter\Tests\Fixtures\LazyGhostObject\MagicClass; +use Symfony\Component\VarExporter\Tests\Fixtures\LazyGhostObject\TestClass; + +class LazyGhostObjectTraitTest extends TestCase +{ + public function testGetPublic() + { + $instance = ChildTestClass::createLazyGhostObject(function (ChildTestClass $ghost) { + $ghost->__construct(); + }); + + $this->assertSame(["\0".TestClass::class."\0lazyGhostObjectId"], array_keys((array) $instance)); + $this->assertSame(-4, $instance->public); + $this->assertSame(4, $instance->publicReadonly); + } + + public function testIssetPublic() + { + $instance = ChildTestClass::createLazyGhostObject(function (ChildTestClass $ghost) { + $ghost->__construct(); + }); + + $this->assertSame(["\0".TestClass::class."\0lazyGhostObjectId"], array_keys((array) $instance)); + $this->assertTrue(isset($instance->public)); + $this->assertSame(4, $instance->publicReadonly); + } + + public function testUnsetPublic() + { + $instance = ChildTestClass::createLazyGhostObject(function (ChildTestClass $ghost) { + $ghost->__construct(); + }); + + $this->assertSame(["\0".TestClass::class."\0lazyGhostObjectId"], array_keys((array) $instance)); + unset($instance->public); + $this->assertFalse(isset($instance->public)); + $this->assertSame(4, $instance->publicReadonly); + } + + public function testSetPublic() + { + $instance = ChildTestClass::createLazyGhostObject(function (ChildTestClass $ghost) { + $ghost->__construct(); + }); + + $this->assertSame(["\0".TestClass::class."\0lazyGhostObjectId"], array_keys((array) $instance)); + $instance->public = 12; + $this->assertSame(12, $instance->public); + $this->assertSame(4, $instance->publicReadonly); + } + + public function testClone() + { + $instance = ChildTestClass::createLazyGhostObject(function (ChildTestClass $ghost) { + $ghost->__construct(); + }); + + $clone = clone $instance; + + $this->assertNotSame((array) $instance, (array) $clone); + $this->assertSame(["\0".TestClass::class."\0lazyGhostObjectId"], array_keys((array) $instance)); + $this->assertSame(["\0".TestClass::class."\0lazyGhostObjectId"], array_keys((array) $clone)); + } + + public function testSerialize() + { + $instance = ChildTestClass::createLazyGhostObject(function (ChildTestClass $ghost) { + $ghost->__construct(); + }); + + $serialized = serialize($instance); + $this->assertStringNotContainsString('lazyGhostObjectId', $serialized); + + $clone = unserialize($serialized); + $this->assertSame(array_keys((array) $instance), array_keys((array) $clone)); + $this->assertFalse($clone->resetLazyGhostObject()); + } + + /** + * @dataProvider provideMagicClass + */ + public function testMagicClass(MagicClass $instance) + { + $this->assertSame('bar', $instance->foo); + $instance->foo = 123; + $this->assertSame(123, $instance->foo); + $this->assertTrue(isset($instance->foo)); + unset($instance->foo); + $this->assertFalse(isset($instance->foo)); + + $clone = clone $instance; + $this->assertSame(0, $instance->cloneCounter); + $this->assertSame(1, $clone->cloneCounter); + + $instance->bar = 123; + $serialized = serialize($instance); + $clone = unserialize($serialized); + $this->assertSame(123, $clone->bar); + } + + public function provideMagicClass() + { + yield [new MagicClass()]; + + yield [ChildMagicClass::createLazyGhostObject(function (ChildMagicClass $instance) { + $instance->__construct(); + })]; + } + + public function testDestruct() + { + $registryCount = \count(GhostObjectRegistry::$states); + $destructCounter = MagicClass::$destructCounter; + + $instance = ChildMagicClass::createLazyGhostObject(function (ChildMagicClass $instance) { + $instance->__construct(); + }); + + unset($instance); + $this->assertSame($destructCounter, MagicClass::$destructCounter); + + $instance = ChildMagicClass::createLazyGhostObject(function (ChildMagicClass $instance) { + $instance->__construct(); + }); + $instance->initializeLazyGhostObject(); + unset($instance); + + $this->assertSame(1 + $destructCounter, MagicClass::$destructCounter); + + $this->assertCount($registryCount, GhostObjectRegistry::$states); + } + + public function testResetLazyGhostObject() + { + $instance = ChildMagicClass::createLazyGhostObject(function (ChildMagicClass $instance) { + $instance->__construct(); + }); + + $instance->foo = 234; + $this->assertTrue($instance->resetLazyGhostObject()); + $this->assertSame('bar', $instance->foo); + } + + public function testFullInitialization() + { + $counter = 0; + $instance = ChildTestClass::createLazyGhostObject(function (ChildTestClass $ghost) use (&$counter) { + ++$counter; + $ghost->__construct(); + }); + + $this->assertTrue(isset($instance->public)); + $this->assertSame(-4, $instance->public); + $this->assertSame(4, $instance->publicReadonly); + $this->assertSame(1, $counter); + } + + public function testPartialInitialization() + { + $counter = 0; + $instance = ChildTestClass::createLazyGhostObject(function (ChildTestClass $instance, string $propertyName, ?string $propertyScope) use (&$counter) { + ++$counter; + + return match ($propertyName) { + 'public' => 123, + 'publicReadonly' => 234, + 'protected' => 345, + 'protectedReadonly' => 456, + 'private' => match ($propertyScope) { + TestClass::class => 567, + ChildTestClass::class => 678, + }, + }; + }); + + $this->assertSame(["\0".TestClass::class."\0lazyGhostObjectId"], array_keys((array) $instance)); + $this->assertSame(123, $instance->public); + $this->assertSame(['public', "\0".TestClass::class."\0lazyGhostObjectId"], array_keys((array) $instance)); + $this->assertSame(1, $counter); + + $instance->initializeLazyGhostObject(); + $this->assertSame(123, $instance->public); + $this->assertSame(6, $counter); + + $properties = (array) $instance; + $this->assertSame(array_keys((array) new ChildTestClass()), array_keys($properties)); + $this->assertSame([123, 345, 456, 567, spl_object_id($instance), 234, 678], array_values($properties)); + } + + public function testPartialInitializationWithReset() + { + $instance = ChildTestClass::createLazyGhostObject(function (ChildTestClass $instance, string $propertyName, ?string $propertyScope) { + return 234; + }); + + $instance->public = 123; + + $this->assertSame(234, $instance->publicReadonly); + $this->assertSame(123, $instance->public); + + $this->assertTrue($instance->resetLazyGhostObject()); + $this->assertSame(234, $instance->publicReadonly); + $this->assertSame(123, $instance->public); + + $instance = ChildTestClass::createLazyGhostObject(function (ChildTestClass $instance, string $propertyName, ?string $propertyScope) { + return 234; + }); + + $instance->resetLazyGhostObject(); + + $instance->public = 123; + $this->assertSame(123, $instance->public); + + $this->assertTrue($instance->resetLazyGhostObject()); + $this->assertSame(234, $instance->public); + } + + public function testPartialInitializationWithNastyPassByRef() + { + $instance = ChildTestClass::createLazyGhostObject(function (ChildTestClass $instance, string &$propertyName, ?string &$propertyScope) { + return $propertyName = $propertyScope = 123; + }); + + $this->assertSame(123, $instance->public); + } +}