mirror of
https://github.com/symfony/symfony.git
synced 2026-03-24 00:32:15 +01:00
[VarExporter] Add DeepCloner to deep-clone PHP values while preserving copy-on-write benefits
This commit is contained in:
1
.github/patch-types.php
vendored
1
.github/patch-types.php
vendored
@@ -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/'):
|
||||
|
||||
@@ -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()))));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <p@tchwork.com>
|
||||
@@ -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;
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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 <p@tchwork.com>
|
||||
@@ -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());
|
||||
|
||||
@@ -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 <p@tchwork.com>
|
||||
@@ -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);
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
namespace Symfony\Component\Form\Flow\DataStorage;
|
||||
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\VarExporter\DeepCloner;
|
||||
|
||||
/**
|
||||
* @author Yonel Ceruto <open@yceruto.dev>
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
8.1
|
||||
---
|
||||
|
||||
* Add `DeepCloner` to deep-clone PHP values while preserving copy-on-write benefits
|
||||
|
||||
8.0
|
||||
---
|
||||
|
||||
|
||||
482
src/Symfony/Component/VarExporter/DeepCloner.php
Normal file
482
src/Symfony/Component/VarExporter/DeepCloner.php
Normal file
@@ -0,0 +1,482 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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 <p@tchwork.com>
|
||||
*/
|
||||
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<U> $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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
------------
|
||||
|
||||
|
||||
428
src/Symfony/Component/VarExporter/Tests/DeepCloneTest.php
Normal file
428
src/Symfony/Component/VarExporter/Tests/DeepCloneTest.php
Normal file
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user