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
6 changes: 3 additions & 3 deletions src/DeepClone/DeepClone.php
Original file line number Diff line number Diff line change
Expand Up @@ -847,9 +847,9 @@ private static function reconstruct($prepared, $objectMeta, $numObjects, $proper
$resolveScope = $resolve[$scope];
}
foreach ($scopeProps as $name => $idValues) {
if (!\is_string($name)) {
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" inner keys must be of type string');
}
// Numeric property names (e.g. $o->{'999'}) surface as integer
// array keys because PHP normalizes numeric string keys; accept
// them as-is, the same way unserialize() round-trips them.
if (!\is_array($idValues)) {
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" value for "'.$scope.'::'.$name.'" must be of type array, '.self::valueName($idValues).' given');
}
Expand Down
67 changes: 67 additions & 0 deletions tests/DeepClone/DeepCloneTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1461,6 +1461,73 @@ public function testHydrateIntegerKeyInsideScopeMatchesUnserialize()
$this->assertSame('val', $o->{'0'});
}

public function testNumericPropertyNameRoundTrip()
{
// GH-64548: a numeric property name like $o->{'999'} must round-trip
// the same way serialize()/unserialize() handles it. PHP normalizes
// numeric string keys to integers, so the wire format uses an int key.
$cfg = new \stdClass();
$cfg->{'999'} = ['TST'];

$d = deepclone_to_array($cfg);
$this->assertSame(999, array_key_first($d['properties']['stdClass']));

$clone = deepclone_from_array($d);
$this->assertEquals($cfg, $clone);
$this->assertSame(['TST'], $clone->{'999'});
}

public function testNumericPropertyNameSurvivesSerializationFormats()
{
// The int-keyed payload must survive a var_export()/require round-trip
// (the OPcache cache.php use case) and a JSON round-trip, both of which
// re-normalize a "999" key back to the integer 999.
$o = new \stdClass();
$o->{'0'} = 'zero';
$o->normal = 1;

$d = deepclone_to_array($o);
$viaExport = deepclone_from_array(eval('return '.var_export($d, true).';'));
$viaJson = deepclone_from_array(json_decode(json_encode($d), true));

$this->assertEquals($o, $viaExport);
$this->assertEquals($o, $viaJson);
}

public function testNumericPropertyLeadingZeroStaysString()
{
// "007" is not a canonical integer key, so it stays a string — exactly
// as PHP arrays and serialize() treat it.
$o = new \stdClass();
$o->{'007'} = 'keepstr';
$o->{'8'} = 'int';

$d = deepclone_to_array($o);
$this->assertSame(['007', 8], array_keys($d['properties']['stdClass']));
$this->assertEquals($o, deepclone_from_array($d));
}

public function testNumericPropertyOnTypedObjectRoundTrip()
{
$f = new DeepCloneNumericHolder();
$f->{'999'} = ['TST'];
$f->a = 5;

$this->assertEquals($f, deepclone_from_array(deepclone_to_array($f)));
}

public function testNumericPropertyPreservesSharedObjectIdentity()
{
$shared = new \stdClass();
$shared->x = 1;
$o = new \stdClass();
$o->{'42'} = $shared;
$o->ref2 = $shared;

$clone = deepclone_from_array(deepclone_to_array($o));
$this->assertSame($clone->{'42'}, $clone->ref2);
}

public function testFromArrayRejectsUnloadedScope()
{
$this->expectException(\ValueError::class);
Expand Down
6 changes: 6 additions & 0 deletions tests/DeepClone/fixtures.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ class HydrateBar extends HydrateFoo
private $priv;
}

#[\AllowDynamicProperties]
class DeepCloneNumericHolder
{
public int $a = 1;
}

class CacheIsolationParent
{
private string $priv = 'def';
Expand Down
Loading