diff --git a/src/DeepClone/DeepClone.php b/src/DeepClone/DeepClone.php index 2711034a..197ee3b6 100644 --- a/src/DeepClone/DeepClone.php +++ b/src/DeepClone/DeepClone.php @@ -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 { @@ -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'); } diff --git a/tests/DeepClone/DeepCloneTest.php b/tests/DeepClone/DeepCloneTest.php index 78fee2c5..6d152894 100644 --- a/tests/DeepClone/DeepCloneTest.php +++ b/tests/DeepClone/DeepCloneTest.php @@ -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']); + } }