diff --git a/.github/patch-types.php b/.github/patch-types.php index 2de1cc5eb22..41dd0cd55fe 100644 --- a/.github/patch-types.php +++ b/.github/patch-types.php @@ -54,6 +54,7 @@ foreach ($loader->getClassMap() as $class => $file) { case false !== strpos($file, '/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/WhenTestWithClosure.php'): case false !== strpos($file, '/src/Symfony/Component/Validator/Tests/Fixtures/NestedAttribute/Entity.php'): case false !== strpos($file, '/src/Symfony/Component/VarDumper/Tests/Fixtures/NotLoadableClass.php'): + case false !== strpos($file, '/src/Symfony/Component/VarExporter/DeepCloner'): case false !== strpos($file, '/src/Symfony/Component/VarExporter/Internal'): case false !== strpos($file, '/src/Symfony/Component/VarExporter/Tests/Fixtures/'): case false !== strpos($file, '/src/Symfony/Contracts/'): diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/ContainerBuilderDebugDumpPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/ContainerBuilderDebugDumpPass.php index 456305bc95f..28bf06cb74f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/ContainerBuilderDebugDumpPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/ContainerBuilderDebugDumpPass.php @@ -18,6 +18,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Dumper\XmlDumper; use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\VarExporter\DeepCloner; /** * Dumps the ContainerBuilder to a cache file so that it can be used by @@ -48,11 +49,13 @@ class ContainerBuilderDebugDumpPass implements CompilerPassInterface $file = substr_replace($file, '.ser', -4); try { - $dump = new ContainerBuilder(clone $container->getParameterBag()); - $dump->setDefinitions(unserialize(serialize($container->getDefinitions()))); + $bag = $container->getParameterBag(); + $dump = new ContainerBuilder($bag); + $dump->setDefinitions($container->getDefinitions()); $dump->setAliases($container->getAliases()); - if (($bag = $container->getParameterBag()) instanceof EnvPlaceholderParameterBag) { + if ($bag instanceof EnvPlaceholderParameterBag) { + $dump = DeepCloner::deepClone($dump); (new ResolveEnvPlaceholdersPass(null))->process($dump); $dump->__construct(new EnvPlaceholderParameterBag($container->resolveEnvPlaceholders($this->escapeParameters($bag->all())))); } diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index e0801ef67a0..e1012387f07 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -31,7 +31,8 @@ "symfony/http-kernel": "^7.4|^8.0", "symfony/polyfill-mbstring": "^1.0", "symfony/polyfill-php85": "^1.33", - "symfony/routing": "^7.4|^8.0" + "symfony/routing": "^7.4|^8.0", + "symfony/var-exporter": "^8.1" }, "require-dev": { "doctrine/persistence": "^1.3|^2|^3", diff --git a/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php b/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php index c9ee94ee9c9..8399c966f40 100644 --- a/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/ArrayAdapter.php @@ -18,6 +18,7 @@ use Psr\Log\LoggerAwareTrait; use Symfony\Component\Cache\CacheItem; use Symfony\Component\Cache\Exception\InvalidArgumentException; use Symfony\Component\Cache\ResettableInterface; +use Symfony\Component\VarExporter\DeepCloner; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\NamespacedPoolInterface; @@ -174,8 +175,12 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, NamespacedPoolIn return true; } } - if ($this->storeSerialized && null === $value = $this->freeze($value, $key)) { - return false; + if ($this->storeSerialized) { + try { + $value = $this->freeze($value, $key); + } catch (\ValueError) { + return false; + } } if (null === $expiry && 0 < $this->defaultLifetime) { $expiry = $this->defaultLifetime; @@ -271,12 +276,10 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, NamespacedPoolIn $values = $this->values; foreach ($values as $k => $v) { - if (null === $v || 'N;' === $v) { + if (null === $v) { continue; } - if (!\is_string($v) || !isset($v[2]) || ':' !== $v[1]) { - $values[$k] = serialize($v); - } + $values[$k] = serialize($v instanceof DeepCloner ? $v->clone() : $v); } return $values; @@ -324,57 +327,43 @@ class ArrayAdapter implements AdapterInterface, CacheInterface, NamespacedPoolIn } } - private function freeze($value, string $key): string|int|float|bool|array|\UnitEnum|null + /** + * @throws \ValueError When the value cannot be frozen + */ + private function freeze(mixed $value, string $key): mixed { - if (null === $value) { - return 'N;'; - } - if (\is_string($value)) { - // Serialize strings if they could be confused with serialized objects or arrays - if ('N;' === $value || (isset($value[2]) && ':' === $value[1])) { - return serialize($value); + try { + $cloner = new DeepCloner($value); + } catch (\Exception $e) { + if (!isset($this->expiries[$key])) { + unset($this->values[$key]); } - } elseif (!\is_scalar($value)) { - try { - $serialized = serialize($value); - } catch (\Exception $e) { - if (!isset($this->expiries[$key])) { - unset($this->values[$key]); - } - $type = get_debug_type($value); - $message = \sprintf('Failed to save key "{key}" of type %s: %s', $type, $e->getMessage()); - CacheItem::log($this->logger, $message, ['key' => $key, 'exception' => $e, 'cache-adapter' => get_debug_type($this)]); + $type = get_debug_type($value); + $message = \sprintf('Failed to save key "{key}" of type %s: %s', $type, $e->getMessage()); + CacheItem::log($this->logger, $message, ['key' => $key, 'exception' => $e, 'cache-adapter' => get_debug_type($this)]); - return null; - } - // Keep value serialized if it contains any objects or any internal references - if ('C' === $serialized[0] || 'O' === $serialized[0] || preg_match('/;[OCRr]:[1-9]/', $serialized)) { - return $serialized; - } + throw new \ValueError(); } - return $value; + return $cloner->isStaticValue() ? $value : $cloner; } private function unfreeze(string $key, bool &$isHit): mixed { - if ('N;' === $value = $this->values[$key]) { - return null; - } - if (\is_string($value) && isset($value[2]) && ':' === $value[1]) { + $value = $this->values[$key]; + + if ($value instanceof DeepCloner) { try { - $value = unserialize($value); + return $value->clone(); } catch (\Exception $e) { - CacheItem::log($this->logger, 'Failed to unserialize key "{key}": '.$e->getMessage(), ['key' => $key, 'exception' => $e, 'cache-adapter' => get_debug_type($this)]); - $value = false; - } - if (false === $value) { - $value = null; + CacheItem::log($this->logger, 'Failed to clone key "{key}": '.$e->getMessage(), ['key' => $key, 'exception' => $e, 'cache-adapter' => get_debug_type($this)]); $isHit = false; if (!$this->maxItems) { $this->values[$key] = null; } + + return null; } } diff --git a/src/Symfony/Component/Cache/composer.json b/src/Symfony/Component/Cache/composer.json index ae8f11a7876..52b3c474f56 100644 --- a/src/Symfony/Component/Cache/composer.json +++ b/src/Symfony/Component/Cache/composer.json @@ -26,7 +26,7 @@ "psr/log": "^1.1|^2|^3", "symfony/cache-contracts": "^3.6", "symfony/service-contracts": "^2.5|^3", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/var-exporter": "^8.1" }, "require-dev": { "cache/integration-tests": "dev-master", diff --git a/src/Symfony/Component/DependencyInjection/Compiler/ResolveDecoratorStackPass.php b/src/Symfony/Component/DependencyInjection/Compiler/ResolveDecoratorStackPass.php index d41442047ca..6a7ff2fc75c 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/ResolveDecoratorStackPass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/ResolveDecoratorStackPass.php @@ -18,6 +18,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\VarExporter\DeepCloner; /** * @author Nicolas Grekas @@ -89,7 +90,7 @@ class ResolveDecoratorStackPass implements CompilerPassInterface foreach ($stacks[$id] as $k => $definition) { if ($definition instanceof ChildDefinition && isset($stacks[$definition->getParent()])) { $path[] = $definition->getParent(); - $definition = unserialize(serialize($definition)); // deep clone + $definition = DeepCloner::deepClone($definition); } elseif ($definition instanceof Definition) { $definitions[$decoratedId = $prefix.$k] = $definition; continue; diff --git a/src/Symfony/Component/DependencyInjection/Compiler/ResolveInstanceofConditionalsPass.php b/src/Symfony/Component/DependencyInjection/Compiler/ResolveInstanceofConditionalsPass.php index 5e9d5038d02..da4d4cf924a 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/ResolveInstanceofConditionalsPass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/ResolveInstanceofConditionalsPass.php @@ -16,6 +16,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\RuntimeException; +use Symfony\Component\VarExporter\DeepCloner; /** * Applies instanceof conditionals to definitions. @@ -105,15 +106,7 @@ class ResolveInstanceofConditionalsPass implements CompilerPassInterface $bindings = $definition->getBindings(); $abstract = $container->setDefinition('.abstract.instanceof.'.$id, $definition); $definition->setBindings([]); - $definition = serialize($definition); - - if (Definition::class === $abstract::class) { - // cast Definition to ChildDefinition - $definition = substr_replace($definition, '53', 2, 2); - $definition = substr_replace($definition, 'Child', 44, 0); - } - /** @var ChildDefinition $definition */ - $definition = unserialize($definition); + $definition = (new DeepCloner($definition))->cloneAs(ChildDefinition::class); $definition->setParent($parent); if (null !== $shared && !isset($definition->getChanges()['shared'])) { diff --git a/src/Symfony/Component/DependencyInjection/Loader/Configurator/PrototypeConfigurator.php b/src/Symfony/Component/DependencyInjection/Loader/Configurator/PrototypeConfigurator.php index 95bf8997598..641b57f921a 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/Configurator/PrototypeConfigurator.php +++ b/src/Symfony/Component/DependencyInjection/Loader/Configurator/PrototypeConfigurator.php @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\VarExporter\DeepCloner; /** * @author Nicolas Grekas @@ -54,7 +55,7 @@ class PrototypeConfigurator extends AbstractServiceConfigurator $definition->setAutowired($defaults->isAutowired()); $definition->setAutoconfigured($defaults->isAutoconfigured()); // deep clone, to avoid multiple process of the same instance in the passes - $definition->setBindings(unserialize(serialize($defaults->getBindings()))); + $definition->setBindings(DeepCloner::deepClone($defaults->getBindings())); $definition->setChanges([]); parent::__construct($parent, $definition, $namespace, $defaults->getTags()); diff --git a/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServicesConfigurator.php b/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServicesConfigurator.php index 06cbea40eeb..7ea838b3ef3 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServicesConfigurator.php +++ b/src/Symfony/Component/DependencyInjection/Loader/Configurator/ServicesConfigurator.php @@ -18,6 +18,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\VarExporter\DeepCloner; /** * @author Nicolas Grekas @@ -87,7 +88,7 @@ class ServicesConfigurator extends AbstractConfigurator $definition->setAutowired($defaults->isAutowired()); $definition->setAutoconfigured($defaults->isAutoconfigured()); // deep clone, to avoid multiple process of the same instance in the passes - $definition->setBindings(unserialize(serialize($defaults->getBindings()))); + $definition->setBindings(DeepCloner::deepClone($defaults->getBindings())); $definition->setChanges([]); $configurator = new ServiceConfigurator($this->container, $this->instanceof, true, $this, $definition, $id, $defaults->getTags(), $this->path); diff --git a/src/Symfony/Component/DependencyInjection/Loader/ContentLoaderTrait.php b/src/Symfony/Component/DependencyInjection/Loader/ContentLoaderTrait.php index 27a2009ba4e..fbeb54c04c0 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/ContentLoaderTrait.php +++ b/src/Symfony/Component/DependencyInjection/Loader/ContentLoaderTrait.php @@ -26,6 +26,7 @@ use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\VarExporter\DeepCloner; use Symfony\Component\Yaml\Tag\TaggedValue; /** @@ -647,7 +648,7 @@ trait ContentLoaderTrait if (isset($defaults['bind']) || isset($service['bind'])) { // deep clone, to avoid multiple process of the same instance in the passes $bindings = $definition->getBindings(); - $bindings += isset($defaults['bind']) ? unserialize(serialize($defaults['bind'])) : []; + $bindings += isset($defaults['bind']) ? DeepCloner::deepClone($defaults['bind']) : []; if (isset($service['bind'])) { if (!\is_array($service['bind'])) { diff --git a/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php index f667e7b7b44..25e0ae6b0a5 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php +++ b/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php @@ -28,6 +28,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\VarExporter\DeepCloner; /** * FileLoader is the abstract class used by all built-in loaders that are file based. @@ -129,26 +130,7 @@ abstract class FileLoader extends BaseFileLoader $autoconfigureAttributes = new RegisterAutoconfigureAttributesPass(); $autoconfigureAttributes = $autoconfigureAttributes->accept($prototype) ? $autoconfigureAttributes : null; $classes = $this->findClasses($namespace, $resource, (array) $exclude, $source); - - $getPrototype = static fn () => clone $prototype; - $serialized = serialize($prototype); - - // avoid deep cloning if no definitions are nested - if (strpos($serialized, 'O:48:"Symfony\Component\DependencyInjection\Definition"', 55) - || strpos($serialized, 'O:53:"Symfony\Component\DependencyInjection\ChildDefinition"', 55) - ) { - // prepare for deep cloning - foreach (['Arguments', 'Properties', 'MethodCalls', 'Configurator', 'Factory', 'Bindings'] as $key) { - $serialized = serialize($prototype->{'get'.$key}()); - - if (strpos($serialized, 'O:48:"Symfony\Component\DependencyInjection\Definition"') - || strpos($serialized, 'O:53:"Symfony\Component\DependencyInjection\ChildDefinition"') - ) { - $getPrototype = static fn () => $getPrototype()->{'set'.$key}(unserialize($serialized)); - } - } - } - unset($serialized); + $getPrototype = (new DeepCloner($prototype))->clone(...); foreach ($classes as $class => $errorMessage) { if (null === $errorMessage && $autoconfigureAttributes) { diff --git a/src/Symfony/Component/DependencyInjection/composer.json b/src/Symfony/Component/DependencyInjection/composer.json index 0209644930c..d90341fb854 100644 --- a/src/Symfony/Component/DependencyInjection/composer.json +++ b/src/Symfony/Component/DependencyInjection/composer.json @@ -20,7 +20,7 @@ "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/service-contracts": "^3.6", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/var-exporter": "^8.1" }, "require-dev": { "symfony/config": "^7.4|^8.0", diff --git a/src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php b/src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php index aa5f065bf6e..95b8c52604d 100644 --- a/src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php +++ b/src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Form\Flow\DataStorage; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\VarExporter\DeepCloner; /** * @author Yonel Ceruto @@ -26,7 +27,8 @@ class SessionDataStorage implements DataStorageInterface public function save(object|array $data): void { - $this->requestStack->getSession()->set($this->key, unserialize(serialize($data))); + $data = new DeepCloner($data); + $this->requestStack->getSession()->set($this->key, $data->isStaticValue() ? $data->clone() : $data); } public function load(object|array|null $default = null): object|array|null @@ -35,8 +37,7 @@ class SessionDataStorage implements DataStorageInterface return $default; } - // Deep clone to decouple the returned data from the session's internal storage - return unserialize(serialize($data)); + return $data instanceof DeepCloner ? $data->clone() : $data; } public function clear(): void diff --git a/src/Symfony/Component/Form/composer.json b/src/Symfony/Component/Form/composer.json index b7e6d4bc8ee..d22b82cbefd 100644 --- a/src/Symfony/Component/Form/composer.json +++ b/src/Symfony/Component/Form/composer.json @@ -24,7 +24,8 @@ "symfony/polyfill-intl-icu": "^1.21", "symfony/polyfill-mbstring": "^1.0", "symfony/property-access": "^7.4|^8.0", - "symfony/service-contracts": "^2.5|^3" + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^8.1" }, "require-dev": { "doctrine/collections": "^1.0|^2.0", diff --git a/src/Symfony/Component/VarExporter/CHANGELOG.md b/src/Symfony/Component/VarExporter/CHANGELOG.md index 2697e86120d..54cd857caec 100644 --- a/src/Symfony/Component/VarExporter/CHANGELOG.md +++ b/src/Symfony/Component/VarExporter/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +8.1 +--- + + * Add `DeepCloner` to deep-clone PHP values while preserving copy-on-write benefits + 8.0 --- diff --git a/src/Symfony/Component/VarExporter/DeepCloner.php b/src/Symfony/Component/VarExporter/DeepCloner.php new file mode 100644 index 00000000000..7d0547b3f9f --- /dev/null +++ b/src/Symfony/Component/VarExporter/DeepCloner.php @@ -0,0 +1,482 @@ + + * + * 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\Exception\LogicException; +use Symfony\Component\VarExporter\Exception\NotInstantiableTypeException; +use Symfony\Component\VarExporter\Internal\Exporter; +use Symfony\Component\VarExporter\Internal\Hydrator as InternalHydrator; +use Symfony\Component\VarExporter\Internal\NamedClosure; +use Symfony\Component\VarExporter\Internal\Reference; +use Symfony\Component\VarExporter\Internal\Registry; + +/** + * Deep-clones PHP values while preserving copy-on-write benefits for strings and arrays. + * + * Unlike unserialize(serialize()), this approach does not reallocate strings and scalar-only + * arrays, allowing PHP's copy-on-write mechanism to share memory for these values. + * + * DeepCloner instances are serializable: the serialized form uses a compact representation + * that deduplicates class and property names, typically producing a payload smaller than + * serialize($value) itself. + * + * @template T + * + * @author Nicolas Grekas + */ +final class DeepCloner +{ + private readonly mixed $value; + private readonly mixed $prepared; + private readonly array $objectMeta; + private readonly array $properties; + private readonly array $resolve; + private readonly array $states; + private readonly array $refs; + private readonly array $originals; + + /** + * @param T $value + */ + public function __construct(mixed $value) + { + if (!\is_object($value) && !(\is_array($value) && $value) || $value instanceof \UnitEnum) { + $this->value = $value; + + return; + } + + $objectsPool = new \SplObjectStorage(); + $refsPool = []; + $objectsCount = 0; + $isStatic = true; + $refs = []; + + try { + $prepared = Exporter::prepare([$value], $objectsPool, $refsPool, $objectsCount, $isStatic)[0]; + } finally { + foreach ($refsPool as $i => $v) { + if ($v[0]->count) { + $refs[1 + $i] = $v[2]; + } + $v[0] = $v[1]; + } + } + + if ($isStatic) { + $this->value = $value; + + return; + } + + $canCloneAll = true; + $originals = []; + $objectMeta = []; + $properties = []; + $resolve = []; + $states = []; + + foreach ($objectsPool as $v) { + [$id, $class, $props, $wakeup] = $objectsPool[$v]; + + if (':' !== ($class[1] ?? null)) { + // Pre-warm Registry caches so reconstruct() only reads them + Registry::$reflectors[$class] ??= Registry::getClassReflector($class); + } + + $objectMeta[$id] = [$class, $wakeup]; + + if (0 < $wakeup) { + $states[$wakeup] = $id; + $canCloneAll = false; + } elseif (0 > $wakeup) { + $states[-$wakeup] = [$id, $props]; + $props = []; + $canCloneAll = false; + } + + if ($canCloneAll && (':' === ($class[1] ?? null) || !Registry::$cloneable[$class])) { + $canCloneAll = false; + } + + if ($canCloneAll) { + $originals[$id] = clone $v; + } + + foreach ($props as $scope => $scopeProps) { + foreach ($scopeProps as $name => $propValue) { + $properties[$scope][$name][$id] = $propValue; + if ($propValue instanceof Reference || $propValue instanceof NamedClosure || \is_array($propValue) && self::hasReference($propValue)) { + $resolve[$scope][$name][] = $id; + } + } + } + } + + ksort($states); + + $this->prepared = $prepared instanceof Reference && $prepared->id >= 0 && !$prepared->count ? $prepared->id : $prepared; + $this->objectMeta = $objectMeta; + $this->properties = $properties; + $this->resolve = $resolve; + $this->states = $states; + $this->refs = $refs; + $this->originals = $canCloneAll ? $originals : []; + } + + /** + * Deep-clones a PHP value. + * + * @template U + * + * @param U $value + * + * @return U + */ + public static function deepClone(mixed $value): mixed + { + return (new self($value))->clone(); + } + + /** + * Returns true when the value doesn't need cloning (scalars, null, enums, scalar-only arrays). + */ + public function isStaticValue(): bool + { + return !isset($this->prepared); + } + + /** + * Creates a deep clone of the value. + * + * @return T + */ + public function clone(): mixed + { + if (!isset($this->prepared)) { + return $this->value; + } + + return self::reconstruct($this->prepared, $this->objectMeta, $this->properties, $this->resolve, $this->states, $this->refs, $this->originals ?? []); + } + + /** + * Creates a deep clone of the root object using a different class. + * + * The target class must be compatible with the original (typically in the same hierarchy). + * + * @template U of object + * + * @param class-string $class + * + * @return U + */ + public function cloneAs(string $class): object + { + $prepared = $this->prepared ?? null; + $rootId = \is_int($prepared) ? $prepared : ($prepared instanceof Reference && $prepared->id >= 0 ? $prepared->id : null); + + if (null === $rootId) { + throw new LogicException('DeepCloner::cloneAs() requires the value to be an object.'); + } + + $objectMeta = $this->objectMeta; + $objectMeta[$rootId][0] = $class; + + return self::reconstruct($prepared, $objectMeta, $this->properties, $this->resolve, $this->states, $this->refs); + } + + public function __serialize(): array + { + if (!isset($this->prepared)) { + return ['value' => $this->value]; + } + + // Deduplicate class names in objectMeta + $classes = []; + $classMap = []; + $objectMeta = []; + foreach ($this->objectMeta as $id => [$class, $wakeup]) { + if (!isset($classMap[$class])) { + $classMap[$class] = \count($classes); + $classes[] = $class; + } + $objectMeta[$id] = 0 !== $wakeup ? [$classMap[$class], $wakeup] : $classMap[$class]; + } + + // When all entries share class index 0 with wakeup 0, store just the count + $n = \count($objectMeta); + foreach ($objectMeta as $v) { + if (0 !== $v) { + $n = $objectMeta; + break; + } + } + + // Replace References in prepared with int ids, tracking positions via mask + $mask = null; + $prepared = self::replaceRefs($this->prepared, $mask); + + $data = [ + 'classes' => 1 === \count($classes) ? $classes[0] : $classes, + 'objectMeta' => $n, + 'prepared' => $prepared, + ]; + + if ($mask) { + $data['mask'] = $mask; + } + + // Replace direct References in properties with their int id (using resolve map) + $properties = $this->properties ?? []; + foreach (($this->resolve ?? []) as $scope => $names) { + foreach ($names as $name => $ids) { + foreach ($ids as $id) { + if ($properties[$scope][$name][$id] instanceof Reference) { + $properties[$scope][$name][$id] = $properties[$scope][$name][$id]->id; + } + } + } + } + + if ($properties) { + $data['properties'] = $properties; + } + if ($this->resolve ?? []) { + $data['resolve'] = $this->resolve; + } + if ($this->states ?? []) { + $data['states'] = $this->states; + } + if ($this->refs ?? []) { + $data['refs'] = $this->refs; + } + + return $data; + } + + public function __unserialize(array $data): void + { + if (\array_key_exists('value', $data)) { + $this->value = $data['value']; + + return; + } + + // Rebuild class names from deduplicated list + $classes = $data['classes']; + if (!\is_array($classes)) { + $classes = [$classes]; + } + $meta = $data['objectMeta']; + if (\is_int($meta)) { + $objectMeta = array_fill(0, $meta, [$classes[0], 0]); + } else { + $objectMeta = []; + foreach ($meta as $id => $v) { + $objectMeta[$id] = \is_array($v) ? [$classes[$v[0]], $v[1]] : [$classes[$v], 0]; + } + } + + $prepared = $data['prepared']; + if (isset($data['mask'])) { + $prepared = self::restoreRefs($prepared, $data['mask']); + } + $this->prepared = $prepared; + $this->objectMeta = $objectMeta; + + // Restore References in properties using the resolve map + $properties = $data['properties'] ?? []; + $resolve = $data['resolve'] ?? []; + foreach ($resolve as $scope => $names) { + foreach ($names as $name => $ids) { + foreach ($ids as $id) { + if (\is_int($properties[$scope][$name][$id])) { + $properties[$scope][$name][$id] = new Reference($properties[$scope][$name][$id]); + } + } + } + } + + $this->properties = $properties; + $this->resolve = $resolve; + $this->states = $data['states'] ?? []; + $this->refs = $data['refs'] ?? []; + } + + private static function reconstruct($prepared, $objectMeta, $properties, $resolve, $states, $refs, $originals = []) + { + // Create all object instances + $objects = []; + + if ($originals) { + // Clone-and-patch: clone originals (COW-shares all scalar properties) + foreach ($originals as $id => $v) { + $objects[$id] = clone $v; + } + } else { + foreach ($objectMeta as $id => [$class]) { + if (':' === ($class[1] ?? null)) { + $objects[$id] = unserialize($class); + continue; + } + Registry::$reflectors[$class] ??= Registry::getClassReflector($class); + + if (Registry::$cloneable[$class]) { + $objects[$id] = clone Registry::$prototypes[$class]; + } elseif (Registry::$instantiableWithoutConstructor[$class]) { + $objects[$id] = Registry::$reflectors[$class]->newInstanceWithoutConstructor(); + } elseif (null === Registry::$prototypes[$class]) { + throw new NotInstantiableTypeException($class); + } elseif (Registry::$reflectors[$class]->implementsInterface('Serializable') && !method_exists($class, '__unserialize')) { + $objects[$id] = unserialize('C:'.\strlen($class).':"'.$class.'":0:{}'); + } else { + $objects[$id] = unserialize('O:'.\strlen($class).':"'.$class.'":0:{}'); + } + } + } + + // Resolve hard references + foreach ($refs as &$ref) { + $ref = self::resolve($ref, $objects, $refs); + } + unset($ref); + + if ($originals) { + // Clone-and-patch: only resolve and hydrate object-reference properties + foreach ($resolve as $scope => $names) { + $scopeProps = []; + foreach ($names as $name => $ids) { + foreach ($ids as $id) { + $scopeProps[$name][$id] = self::resolve($properties[$scope][$name][$id], $objects, $refs); + } + } + (InternalHydrator::$hydrators[$scope] ??= InternalHydrator::getHydrator($scope))($scopeProps, $objects); + } + } else { + // Full hydration: resolve object refs in-place, then hydrate all properties + foreach ($resolve as $scope => $names) { + foreach ($names as $name => $ids) { + foreach ($ids as $id) { + $properties[$scope][$name][$id] = self::resolve($properties[$scope][$name][$id], $objects, $refs); + } + } + } + foreach ($properties as $scope => $scopeProps) { + (InternalHydrator::$hydrators[$scope] ??= InternalHydrator::getHydrator($scope))($scopeProps, $objects); + } + } + + foreach ($states as $v) { + if (\is_array($v)) { + $objects[$v[0]]->__unserialize(self::resolve($v[1], $objects, $refs)); + } else { + $objects[$v]->__wakeup(); + } + } + + if (\is_int($prepared)) { + return $objects[$prepared]; + } + + if ($prepared instanceof Reference) { + return $prepared->id >= 0 ? $objects[$prepared->id] : ($prepared->count ? $refs[-$prepared->id] : self::resolve($prepared->value, $objects, $refs)); + } + + return self::resolve($prepared, $objects, $refs); + } + + private static function hasReference($value) + { + foreach ($value as $v) { + if ($v instanceof Reference || $v instanceof NamedClosure || \is_array($v) && self::hasReference($v)) { + return true; + } + } + + return false; + } + + private static function resolve($value, $objects, $refs) + { + if ($value instanceof Reference) { + if ($value->id >= 0) { + return $objects[$value->id]; + } + if (!$value->count) { + return self::resolve($value->value, $objects, $refs); + } + + return $refs[-$value->id]; + } + + if ($value instanceof NamedClosure) { + $callable = self::resolve($value->callable, $objects, $refs); + if ($value->method?->isPublic() ?? true) { + return $callable[0] ? $callable[0]->$callable[1](...) : $callable[1](...); + } + + return $value->method->getClosure(\is_object($callable[0]) ? $callable[0] : null); + } + + if (\is_array($value)) { + foreach ($value as $k => $v) { + if ($v instanceof Reference || $v instanceof NamedClosure || \is_array($v)) { + $value[$k] = self::resolve($v, $objects, $refs); + } + } + } + + return $value; + } + + private static function replaceRefs($value, &$mask) + { + if ($value instanceof Reference) { + if ($value->id < 0) { + return $value; // Hard ref - serialize natively + } + $mask = true; + + return $value->id; + } + + if (\is_array($value)) { + foreach ($value as $k => $v) { + if ($v instanceof Reference || \is_array($v)) { + $m = null; + $value[$k] = self::replaceRefs($v, $m); + if (null !== $m) { + $mask[$k] = $m; + } + } + } + } + + return $value; + } + + private static function restoreRefs($value, $mask) + { + if (true === $mask) { + return new Reference($value); + } + + if (\is_array($mask)) { + foreach ($mask as $k => $m) { + $value[$k] = self::restoreRefs($value[$k], $m); + } + } + + return $value; + } +} diff --git a/src/Symfony/Component/VarExporter/Internal/Exporter.php b/src/Symfony/Component/VarExporter/Internal/Exporter.php index eca8b806dc1..39f18ebf909 100644 --- a/src/Symfony/Component/VarExporter/Internal/Exporter.php +++ b/src/Symfony/Component/VarExporter/Internal/Exporter.php @@ -20,12 +20,16 @@ use Symfony\Component\VarExporter\Exception\NotInstantiableTypeException; */ class Exporter { + private static array $scopeMaps = []; + private static array $protos = []; + private static array $classInfo = []; + /** * Prepares an array of values for VarExporter. * * For performance this method is public and has no type-hints. * - * @param array &$values + * @param array $values * @param \SplObjectStorage $objectsPool * @param array &$refsPool * @param int &$objectsCount @@ -87,16 +91,16 @@ class Exporter $sleep = null; $proto = Registry::$prototypes[$class]; - if ($reflector->hasMethod('__serialize')) { - if (!$reflector->getMethod('__serialize')->isPublic()) { - throw new \Error(\sprintf('Call to %s method "%s::__serialize()".', $reflector->getMethod('__serialize')->isProtected() ? 'protected' : 'private', $class)); + if (self::$classInfo[$class][2] ??= $reflector->hasMethod('__serialize') ? ($reflector->getMethod('__serialize')->isPublic() ?: $reflector->getMethod('__serialize')) : false) { + if (self::$classInfo[$class][2] instanceof \ReflectionMethod) { + throw new \Error(\sprintf('Call to %s method "%s::__serialize()".', self::$classInfo[$class][2]->isProtected() ? 'protected' : 'private', $class)); } if (!\is_array($arrayValue = $value->__serialize())) { throw new \TypeError($class.'::__serialize() must return an array'); } - if ($reflector->hasMethod('__unserialize')) { + if (self::$classInfo[$class][0] ??= method_exists($class, '__unserialize')) { $properties = $arrayValue; goto prepare_value; } @@ -122,7 +126,7 @@ class Exporter $value = new Reference($id); goto handle_value; } else { - if (method_exists($class, '__sleep')) { + if (self::$classInfo[$class][3] ??= method_exists($class, '__sleep')) { if (!\is_array($sleep = $value->__sleep())) { trigger_error('serialize(): __sleep should return an array only containing the names of instance-variables to serialize', \E_USER_NOTICE); $value = null; @@ -134,21 +138,29 @@ class Exporter $arrayValue = (array) $value; } - $proto = (array) $proto; + $proto = self::$protos[$class] ??= (array) $proto; + + if (null === $scopeMap = self::$scopeMaps[$class] ?? null) { + $scopeMap = []; + $parent = $reflector; + do { + foreach ($parent->getProperties() as $p) { + if (!$p->isStatic() && !isset($scopeMap[$p->name])) { + $scopeMap[$p->name] = !$p->isPublic() || $p->isProtectedSet() || $p->isPrivateSet() ? $p->class : 'stdClass'; + } + } + } while ($parent = $parent->getParentClass()); + self::$scopeMaps[$class] = $scopeMap; + } foreach ($arrayValue as $name => $v) { $i = 0; $n = (string) $name; if ('' === $n || "\0" !== $n[0]) { - $parent = $reflector; - do { - $p = $parent->hasProperty($n) ? $parent->getProperty($n) : null; - } while (!$p && $parent = $parent->getParentClass()); - - $c = $p && (!$p->isPublic() || $p->isProtectedSet() || $p->isPrivateSet()) ? $p->class : 'stdClass'; + $c = $scopeMap[$n] ?? 'stdClass'; } elseif ('*' === $n[1]) { $n = substr($n, 3); - $c = $reflector->getProperty($n)->class; + $c = $scopeMap[$n] ?? $reflector->getProperty($n)->class; } else { $i = strpos($n, "\0", 2); $c = substr($n, 1, $i - 1); @@ -176,15 +188,17 @@ class Exporter trigger_error(\sprintf('serialize(): "%s" returned as member variable from __sleep() but does not exist', $n), \E_USER_NOTICE); } } - if (method_exists($class, '__unserialize')) { + $hasUnserialize = self::$classInfo[$class][0] ??= method_exists($class, '__unserialize'); + if ($hasUnserialize) { $properties = $arrayValue; } prepare_value: + $hasUnserialize ??= self::$classInfo[$class][0] ??= method_exists($class, '__unserialize'); $objectsPool[$value] = [$id = \count($objectsPool)]; $properties = self::prepare($properties, $objectsPool, $refsPool, $objectsCount, $valueIsStatic); ++$objectsCount; - $objectsPool[$value] = [$id, $class, $properties, method_exists($class, '__unserialize') ? -$objectsCount : (method_exists($class, '__wakeup') ? $objectsCount : 0)]; + $objectsPool[$value] = [$id, $class, $properties, $hasUnserialize ? -$objectsCount : ((self::$classInfo[$class][1] ??= method_exists($class, '__wakeup')) ? $objectsCount : 0)]; $value = new Reference($id); diff --git a/src/Symfony/Component/VarExporter/Internal/Hydrator.php b/src/Symfony/Component/VarExporter/Internal/Hydrator.php index 8f923ec31d4..900c43052fb 100644 --- a/src/Symfony/Component/VarExporter/Internal/Hydrator.php +++ b/src/Symfony/Component/VarExporter/Internal/Hydrator.php @@ -92,10 +92,12 @@ class Hydrator }; } - if (!class_exists($class) && !interface_exists($class, false) && !trait_exists($class, false)) { - throw new ClassNotFoundException($class); + if (!$classReflector = Registry::$reflectors[$class] ?? null) { + if (!class_exists($class) && !interface_exists($class, false) && !trait_exists($class, false)) { + throw new ClassNotFoundException($class); + } + $classReflector = Registry::$reflectors[$class] = new \ReflectionClass($class); } - $classReflector = new \ReflectionClass($class); switch ($class) { case 'ArrayIterator': @@ -194,10 +196,12 @@ class Hydrator }; } - if (!class_exists($class) && !interface_exists($class, false) && !trait_exists($class, false)) { - throw new ClassNotFoundException($class); + if (!$classReflector = Registry::$reflectors[$class] ?? null) { + if (!class_exists($class) && !interface_exists($class, false) && !trait_exists($class, false)) { + throw new ClassNotFoundException($class); + } + $classReflector = Registry::$reflectors[$class] = new \ReflectionClass($class); } - $classReflector = new \ReflectionClass($class); switch ($class) { case 'ArrayIterator': @@ -262,7 +266,7 @@ class Hydrator public static function getPropertyScopes($class): array { $propertyScopes = []; - $r = new \ReflectionClass($class); + $r = Registry::$reflectors[$class] ?? new \ReflectionClass($class); foreach ($r->getProperties() as $property) { $flags = $property->getModifiers(); diff --git a/src/Symfony/Component/VarExporter/README.md b/src/Symfony/Component/VarExporter/README.md index e470bbb9955..5685cd21bb7 100644 --- a/src/Symfony/Component/VarExporter/README.md +++ b/src/Symfony/Component/VarExporter/README.md @@ -11,6 +11,9 @@ of objects: - `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; +- `DeepCloner` deep-clones PHP values while preserving copy-on-write benefits + for strings and arrays, making it faster and more memory efficient than + `unserialize(serialize())`; - `Lazy*Trait` can make a class behave as a lazy-loading ghost or virtual proxy. VarExporter::export() @@ -57,6 +60,26 @@ Hydrator::hydrate($object, [], [ ]); ``` +DeepCloner +---------- + +`DeepCloner::deepClone()` deep-clones a PHP value. Unlike +`unserialize(serialize())`, it preserves PHP's copy-on-write semantics for +strings and arrays, resulting in lower memory usage and better performance: + +```php +$clone = DeepCloner::deepClone($originalObject); +``` + +For repeated cloning of the same structure, create an instance to amortize the +cost of graph analysis: + +```php +$cloner = new DeepCloner($prototype); +$clone1 = $cloner->clone(); +$clone2 = $cloner->clone(); +``` + Lazy Proxies ------------ diff --git a/src/Symfony/Component/VarExporter/Tests/DeepCloneTest.php b/src/Symfony/Component/VarExporter/Tests/DeepCloneTest.php new file mode 100644 index 00000000000..06bf6188db4 --- /dev/null +++ b/src/Symfony/Component/VarExporter/Tests/DeepCloneTest.php @@ -0,0 +1,428 @@ + + * + * 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\DeepCloner; +use Symfony\Component\VarExporter\Exception\LogicException; +use Symfony\Component\VarExporter\Tests\Fixtures\FooReadonly; +use Symfony\Component\VarExporter\Tests\Fixtures\FooUnitEnum; +use Symfony\Component\VarExporter\Tests\Fixtures\GoodNight; +use Symfony\Component\VarExporter\Tests\Fixtures\MyWakeup; + +class DeepCloneTest extends TestCase +{ + public function testScalars() + { + $this->assertSame(42, DeepCloner::deepClone(42)); + $this->assertSame('hello', DeepCloner::deepClone('hello')); + $this->assertSame(3.14, DeepCloner::deepClone(3.14)); + $this->assertTrue(DeepCloner::deepClone(true)); + $this->assertNull(DeepCloner::deepClone(null)); + } + + public function testSimpleArray() + { + $arr = ['a', 'b', [1, 2, 3]]; + $clone = DeepCloner::deepClone($arr); + $this->assertSame($arr, $clone); + } + + public function testUnitEnum() + { + $enum = FooUnitEnum::Bar; + $this->assertSame($enum, DeepCloner::deepClone($enum)); + } + + public function testSimpleObject() + { + $obj = new \stdClass(); + $obj->foo = 'bar'; + $obj->baz = 123; + + $clone = DeepCloner::deepClone($obj); + + $this->assertNotSame($obj, $clone); + $this->assertEquals($obj, $clone); + $this->assertSame('bar', $clone->foo); + $this->assertSame(123, $clone->baz); + } + + public function testNestedObjects() + { + $inner = new \stdClass(); + $inner->value = 'inner'; + + $outer = new \stdClass(); + $outer->child = $inner; + $outer->name = 'outer'; + + $clone = DeepCloner::deepClone($outer); + + $this->assertNotSame($outer, $clone); + $this->assertNotSame($inner, $clone->child); + $this->assertSame('inner', $clone->child->value); + $this->assertSame('outer', $clone->name); + + // Mutating original doesn't affect clone + $inner->value = 'changed'; + $this->assertSame('inner', $clone->child->value); + } + + public function testCircularReference() + { + $a = new \stdClass(); + $b = new \stdClass(); + $a->ref = $b; + $b->ref = $a; + + $clone = DeepCloner::deepClone($a); + + $this->assertNotSame($a, $clone); + $this->assertNotSame($b, $clone->ref); + $this->assertSame($clone, $clone->ref->ref); + } + + public function testArrayWithObjects() + { + $obj = new \stdClass(); + $obj->x = 42; + + $arr = ['key' => $obj, 'str' => 'hello']; + $clone = DeepCloner::deepClone($arr); + + $this->assertNotSame($obj, $clone['key']); + $this->assertSame(42, $clone['key']->x); + $this->assertSame('hello', $clone['str']); + + // Mutating original doesn't affect clone + $obj->x = 99; + $this->assertSame(42, $clone['key']->x); + } + + public function testSameObjectMultipleReferences() + { + $shared = new \stdClass(); + $shared->val = 'shared'; + + $root = new \stdClass(); + $root->a = $shared; + $root->b = $shared; + + $clone = DeepCloner::deepClone($root); + + $this->assertNotSame($shared, $clone->a); + $this->assertSame($clone->a, $clone->b); + } + + public function testSleepWakeup() + { + $obj = new MyWakeup(); + $obj->sub = 123; + $obj->bis = 'ignored_by_sleep'; + $obj->baz = 'baz_value'; + $obj->def = 456; + + $clone = DeepCloner::deepClone($obj); + + $this->assertNotSame($obj, $clone); + // __sleep returns ['sub', 'baz'], so 'bis' and 'def' should be reset + $this->assertSame(123, $clone->sub); + // __wakeup sets bis=123 and baz=123 when sub===123 + $this->assertSame(123, $clone->bis); + $this->assertSame(123, $clone->baz); + // def is not in __sleep, so it gets its default value 234 + $this->assertSame(234, $clone->def); + } + + public function testSerializeUnserialize() + { + $obj = new class { + public string $name = ''; + public array $data = []; + + public function __serialize(): array + { + return ['name' => $this->name, 'data' => $this->data]; + } + + public function __unserialize(array $data): void + { + $this->name = $data['name']; + $this->data = $data['data']; + } + }; + + $obj->name = 'test'; + $obj->data = ['a', 'b', 'c']; + + $clone = DeepCloner::deepClone($obj); + + $this->assertNotSame($obj, $clone); + $this->assertSame('test', $clone->name); + $this->assertSame(['a', 'b', 'c'], $clone->data); + } + + public function testReadonlyProperties() + { + $obj = new FooReadonly('hello', 'world'); + + $clone = DeepCloner::deepClone($obj); + + $this->assertNotSame($obj, $clone); + $this->assertSame('hello', $clone->name); + $this->assertSame('world', $clone->value); + } + + public function testPrivateAndProtectedProperties() + { + $obj = new GoodNight(); + // __construct: unset($this->good), $this->foo='afternoon', $this->bar='morning' + + $clone = DeepCloner::deepClone($obj); + + $this->assertNotSame($obj, $clone); + $this->assertEquals($obj, $clone); + } + + public function testDateTime() + { + $dt = new \DateTime('2024-01-15 10:30:00', new \DateTimeZone('UTC')); + + $clone = DeepCloner::deepClone($dt); + + $this->assertNotSame($dt, $clone); + $this->assertEquals($dt, $clone); + + // Mutating original doesn't affect clone + $dt->modify('+1 day'); + $this->assertNotEquals($dt, $clone); + } + + public function testSplObjectStorage() + { + $s = new \SplObjectStorage(); + $o1 = new \stdClass(); + $o1->id = 1; + $o2 = new \stdClass(); + $o2->id = 2; + $s[$o1] = 'info1'; + $s[$o2] = 'info2'; + + $clone = DeepCloner::deepClone($s); + + $this->assertNotSame($s, $clone); + $this->assertCount(2, $clone); + } + + public function testDeepCloneMatchesSerializeUnserialize() + { + $inner = new \stdClass(); + $inner->value = str_repeat('x', 1000); + + $outer = new \stdClass(); + $outer->child = $inner; + $outer->items = ['a', 'b', $inner]; + $outer->number = 42; + + $cloneA = unserialize(serialize($outer)); + $cloneB = DeepCloner::deepClone($outer); + + $this->assertEquals($cloneA, $cloneB); + } + + public function testNamedClosure() + { + $fn = strlen(...); + $clone = DeepCloner::deepClone($fn); + + $this->assertSame(5, $clone('hello')); + } + + public function testRepeatedClones() + { + $obj = new \stdClass(); + $obj->value = 'original'; + + $cloner = new DeepCloner($obj); + + $clone1 = $cloner->clone(); + $clone2 = $cloner->clone(); + + $this->assertNotSame($obj, $clone1); + $this->assertNotSame($obj, $clone2); + $this->assertNotSame($clone1, $clone2); + $this->assertEquals($obj, $clone1); + $this->assertEquals($obj, $clone2); + + $clone1->value = 'changed'; + $this->assertSame('original', $clone2->value); + $this->assertSame('original', $obj->value); + } + + public function testRepeatedClonesWithNestedGraph() + { + $a = new \stdClass(); + $b = new \stdClass(); + $a->ref = $b; + $b->ref = $a; + $a->data = str_repeat('x', 1000); + + $cloner = new DeepCloner($a); + + for ($i = 0; $i < 3; ++$i) { + $clone = $cloner->clone(); + $this->assertNotSame($a, $clone); + $this->assertSame($clone, $clone->ref->ref); + $this->assertSame(str_repeat('x', 1000), $clone->data); + } + } + + public function testStaticValues() + { + $cloner = new DeepCloner(42); + $this->assertSame(42, $cloner->clone()); + + $cloner = new DeepCloner(['a', 'b']); + $this->assertSame(['a', 'b'], $cloner->clone()); + } + + public function testCloneAs() + { + $obj = new FooReadonly('hello', 'world'); + + $cloner = new DeepCloner($obj); + $clone = $cloner->cloneAs(FooReadonly::class); + + $this->assertInstanceOf(FooReadonly::class, $clone); + $this->assertNotSame($obj, $clone); + $this->assertSame('hello', $clone->name); + $this->assertSame('world', $clone->value); + } + + public function testCloneAsRequiresObject() + { + $this->expectException(LogicException::class); + $cloner = new DeepCloner([1, 2, new \stdClass()]); + $cloner->cloneAs(\stdClass::class); + } + + public function testOriginalMutationDoesNotAffectClone() + { + $obj = new \stdClass(); + $obj->foo = 'original'; + $obj->child = new \stdClass(); + $obj->child->bar = 'inner'; + + $cloner = new DeepCloner($obj); + + // Mutate original after creating the cloner + $obj->foo = 'mutated'; + $obj->child->bar = 'mutated-inner'; + + $clone = $cloner->clone(); + $this->assertSame('original', $clone->foo); + $this->assertSame('inner', $clone->child->bar); + + // Second clone should also be unaffected + $clone2 = $cloner->clone(); + $this->assertSame('original', $clone2->foo); + $this->assertSame('inner', $clone2->child->bar); + } + + public function testSerializeClonerStaticValue() + { + $cloner = new DeepCloner(42); + $restored = unserialize(serialize($cloner)); + + $this->assertTrue($restored->isStaticValue()); + $this->assertSame(42, $restored->clone()); + } + + public function testSerializeClonerWithObjects() + { + $obj = new \stdClass(); + $obj->foo = 'bar'; + $obj->child = new \stdClass(); + $obj->child->val = 'inner'; + + $cloner = new DeepCloner($obj); + $data = serialize($cloner); + + // originals should not be in serialized form + $this->assertStringNotContainsString('originals', $data); + + $restored = unserialize($data); + $this->assertFalse($restored->isStaticValue()); + + $clone = $restored->clone(); + $this->assertSame('bar', $clone->foo); + $this->assertSame('inner', $clone->child->val); + $this->assertNotSame($clone, $restored->clone()); + } + + public function testSerializeClonerStripsEmptyProperties() + { + $obj = new \stdClass(); + $obj->foo = 'bar'; + + $cloner = new DeepCloner($obj); + $data = serialize($cloner); + + // No refs, no states, no resolve for this simple case - should be absent + $this->assertStringNotContainsString('states', $data); + $this->assertStringNotContainsString('refs', $data); + } + + public function testSerializeClonerWithCircularRef() + { + $a = new \stdClass(); + $b = new \stdClass(); + $a->ref = $b; + $b->ref = $a; + + $cloner = new DeepCloner($a); + $restored = unserialize(serialize($cloner)); + + $clone = $restored->clone(); + $this->assertSame($clone, $clone->ref->ref); + } + + public function testSerializeClonerWithNullByteKey() + { + $obj = new \stdClass(); + $obj->data = ["\0" => 'nul-key', 'normal' => new \stdClass()]; + $obj->data['normal']->x = 42; + + $cloner = new DeepCloner($obj); + $restored = unserialize(serialize($cloner)); + + $clone = $restored->clone(); + $this->assertSame('nul-key', $clone->data["\0"]); + $this->assertSame(42, $clone->data['normal']->x); + $this->assertNotSame($obj->data['normal'], $clone->data['normal']); + } + + public function testIsStaticValue() + { + $this->assertTrue((new DeepCloner(42))->isStaticValue()); + $this->assertTrue((new DeepCloner('hello'))->isStaticValue()); + $this->assertTrue((new DeepCloner(null))->isStaticValue()); + $this->assertTrue((new DeepCloner(true))->isStaticValue()); + $this->assertTrue((new DeepCloner([1, 'a', [2]]))->isStaticValue()); + $this->assertTrue((new DeepCloner([]))->isStaticValue()); + $this->assertTrue((new DeepCloner(FooUnitEnum::Bar))->isStaticValue()); + + $this->assertFalse((new DeepCloner(new \stdClass()))->isStaticValue()); + $this->assertFalse((new DeepCloner(['key' => new \stdClass()]))->isStaticValue()); + } +} diff --git a/src/Symfony/Component/VarExporter/composer.json b/src/Symfony/Component/VarExporter/composer.json index a3ced35d5ac..197c6a0c96d 100644 --- a/src/Symfony/Component/VarExporter/composer.json +++ b/src/Symfony/Component/VarExporter/composer.json @@ -1,8 +1,8 @@ { "name": "symfony/var-exporter", "type": "library", - "description": "Allows exporting any serializable PHP data structure to plain PHP code", - "keywords": ["export", "serialize", "instantiate", "hydrate", "construct", "clone", "lazy-loading", "proxy"], + "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", + "keywords": ["export", "serialize", "instantiate", "hydrate", "construct", "clone", "deep-clone", "lazy-loading", "proxy"], "homepage": "https://symfony.com", "license": "MIT", "authors": [