Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/DeepClone/DeepClone.php
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,16 @@ public static function deepclone_hydrate(object|string $object_or_class, array $

if (\is_string($class = $object_or_class)) {
$r = self::$reflectors[$class] ??= self::getClassReflector($class);
if (self::$cloneable[$class]) {
if (null === self::$prototypes[$class] && !self::$instantiableWithoutConstructor[$class]) {
// No empty-shell prototype exists (e.g. an internal final class
// whose __unserialize() rejects an empty payload, like
// BcMath\Number). Such a class can only be reconstructed via a
// full serialization round-trip, never by property injection.
throw new \DeepClone\NotInstantiableException('Class "'.$class.'" is not instantiable.');
} elseif (self::$cloneable[$class]) {
$object = clone self::$prototypes[$class];
} elseif (self::$instantiableWithoutConstructor[$class]) {
$object = $r->newInstanceWithoutConstructor();
} elseif (null === self::$prototypes[$class]) {
throw new \DeepClone\NotInstantiableException('Class "'.$class.'" is not instantiable.');
} elseif ($r->implementsInterface('Serializable') && !method_exists($class, '__unserialize')) {
$object = unserialize('C:'.\strlen($class).':"'.$class.'":0:{}');
} else {
Expand Down Expand Up @@ -924,6 +928,14 @@ private static function reconstruct($prepared, $objectMeta, $numObjects, $proper
continue;
}
$objClass = $obj::class;
if (self::$needsFullUnserialize[$objectMeta[$zid][0]] ?? false) {
// Already fully reconstructed via the full O: serialization
// form (eager-finalize loop above), which invokes
// __unserialize() internally. Calling it again would re-init
// an already-initialized (often readonly) object, e.g.
// BcMath\Number throws "Cannot modify readonly property".
continue;
}
if (!method_exists($obj, '__unserialize')) {
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" entry references object id '.$zid.' whose class '.$objClass.' has no __unserialize() method');
}
Expand Down
53 changes: 53 additions & 0 deletions tests/DeepClone/DeepCloneTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1903,4 +1903,57 @@ public function testHydrateEnumCastAppliesEvenWhenHookParamIsWider()
$this->assertSame(DeepCloneHydrateSuit::Spades, $o->s);
$this->assertNull(HookedEnumWiderParam::$lastRaw);
}

/**
* @requires PHP 8.4
* @requires extension bcmath
*/
public function testRoundTripBcMathNumber()
{
// BcMath\Number is a final internal class with a custom create_object,
// so newInstanceWithoutConstructor() is rejected and an empty O:
// unserialize is refused by __unserialize(). The round-trip must still
// reconstruct it through a full serialization replay.
$n = new \BcMath\Number('12.34');
$o = (object) ['a' => $n, 'b' => $n, 'list' => [$n, new \BcMath\Number('5')]];

$c = deepclone_from_array(deepclone_to_array($o));

$this->assertInstanceOf(\BcMath\Number::class, $c->a);
$this->assertSame('12.34', (string) $c->a);
$this->assertSame(2, $c->a->scale);
$this->assertNotSame($n, $c->a, 'is a real clone, not the original instance');
$this->assertSame($c->a, $c->b, 'shared identity is preserved');
$this->assertSame($c->a, $c->list[0], 'shared identity is preserved across the graph');
$this->assertSame('5', (string) $c->list[1]);
}

/**
* @requires PHP 8.4
* @requires extension bcmath
*/
public function testRoundTripBcMathNumberTopLevel()
{
$c = deepclone_from_array(deepclone_to_array(new \BcMath\Number('99.999')));

$this->assertInstanceOf(\BcMath\Number::class, $c);
$this->assertSame('99.999', (string) $c);
$this->assertSame(3, $c->scale);
$this->assertSame('100.499', (string) $c->add('0.5'));
}

/**
* @requires PHP 8.4
* @requires extension bcmath
*/
public function testHydrateBcMathNumberThrows()
{
// deepclone_hydrate() injects properties into an empty shell; a class
// that only becomes valid through __construct()/__unserialize() cannot
// be built that way and must be rejected rather than yielding a broken
// (uninitialized) instance.
$this->expectException(\DeepClone\NotInstantiableException::class);
$this->expectExceptionMessage('Class "BcMath\Number" is not instantiable.');
deepclone_hydrate(\BcMath\Number::class, ['value' => '7.5']);
}
}
Loading