From d5f18963890b0b1a31dfa44ee9b6498c75dbc085 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Wed, 20 May 2026 06:34:14 +0200 Subject: [PATCH] Closes #90 --- ChangeLog.md | 7 ++ src/Exporter.php | 73 ++++++++++++++++--- tests/ExporterTest.php | 13 ++++ .../ChildClassWithPrivateProperty.php | 25 +++++++ .../ParentClassWithPrivateProperty.php | 23 ++++++ 5 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 tests/_fixture/ChildClassWithPrivateProperty.php create mode 100644 tests/_fixture/ParentClassWithPrivateProperty.php diff --git a/ChangeLog.md b/ChangeLog.md index 3d6ce31..23db593 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,12 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](https://keepachangelog.com/) principles. +## [7.0.3] - 2026-MM-DD + +### Fixed + +* [#90](https://github.com/sebastianbergmann/exporter/issues/90): `Exporter::toArray()` silently drops a private property that is redeclared in a derived class + ## [7.0.2] - 2025-09-24 ### Changed @@ -20,6 +26,7 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](htt * This component is no longer supported on PHP 8.2 +[7.0.3]: https://github.com/sebastianbergmann/exporter/compare/7.0.2...7.0 [7.0.2]: https://github.com/sebastianbergmann/exporter/compare/7.0.1...7.0.2 [7.0.1]: https://github.com/sebastianbergmann/exporter/compare/7.0.0...7.0.1 [7.0.0]: https://github.com/sebastianbergmann/exporter/compare/6.3...7.0.0 diff --git a/src/Exporter.php b/src/Exporter.php index 25a04fe..100ef5d 100644 --- a/src/Exporter.php +++ b/src/Exporter.php @@ -183,29 +183,43 @@ final readonly class Exporter return (array) $value; } - $array = []; + $properties = (array) $value; + $shadowed = $this->shadowedPropertyNames($properties); + $array = []; + + foreach ($properties as $key => $val) { + $key = (string) $key; - foreach ((array) $value as $key => $val) { // Exception traces commonly reference hundreds to thousands of // objects currently loaded in memory. Including them in the result // has a severe negative performance impact. - if ("\0Error\0trace" === $key || "\0Exception\0trace" === $key) { + if ($key === "\0Error\0trace" || $key === "\0Exception\0trace") { continue; } - // properties are transformed to keys in the following way: - // private $propertyName => "\0ClassName\0propertyName" - // protected $propertyName => "\0*\0propertyName" - // public $propertyName => "propertyName" - if (preg_match('/\0.+\0(.+)/', (string) $key, $matches) === 1) { - $key = $matches[1]; - } - // See https://github.com/php/php-src/commit/5721132 if ($key === "\0gcdata") { continue; } + // Properties are transformed to keys in the following way: + // private $propertyName => "\0DeclaringClassName\0propertyName" + // protected $propertyName => "\0*\0propertyName" + // public $propertyName => "propertyName" + // + // A private property that is redeclared in a derived class and the + // private property of the same name that it shadows both exist, + // independently of each other. To keep one from overwriting the + // other, the name of a shadowed private property is prefixed with + // the name of the class that declares it. + if (preg_match('/^\0([^\0]+)\0([^\0]+)$/', $key, $matches) === 1) { + if ($matches[1] !== '*' && isset($shadowed[$matches[2]])) { + $key = $matches[1] . '::' . $matches[2]; + } else { + $key = $matches[2]; + } + } + $array[$key] = $val; } @@ -242,6 +256,43 @@ final readonly class Exporter return count((new ReflectionObject($value))->getProperties()); } + /** + * Returns, as keys of the returned array, the names of properties that + * are declared more than once in the inheritance chain of an object. + * + * This can only happen when a derived class redeclares a private property + * that one of its parent classes also declares. + * + * @param array $properties + * + * @return array + */ + private function shadowedPropertyNames(array $properties): array + { + $seen = []; + $shadowed = []; + + foreach ($properties as $key => $unused) { + $key = (string) $key; + + if ($key === "\0Error\0trace" || $key === "\0Exception\0trace" || $key === "\0gcdata") { + continue; + } + + if (preg_match('/^\0[^\0]+\0([^\0]+)$/', $key, $matches) === 1) { + $key = $matches[1]; + } + + if (isset($seen[$key])) { + $shadowed[$key] = true; + } + + $seen[$key] = true; + } + + return $shadowed; + } + /** * @param array $data * @param positive-int $maxLengthForStrings diff --git a/tests/ExporterTest.php b/tests/ExporterTest.php index f70d8a2..f3e52cc 100644 --- a/tests/ExporterTest.php +++ b/tests/ExporterTest.php @@ -544,6 +544,19 @@ EOF; $this->assertEquals([], (new Exporter)->toArray((object) $array)); } + public function testPrivatePropertyRedeclaredInDerivedClassIsNotOverwritten(): void + { + $this->assertSame( + [ + ParentClassWithPrivateProperty::class . '::property' => 'parent value', + ChildClassWithPrivateProperty::class . '::property' => 'child value', + ], + (new Exporter)->toArray( + new ChildClassWithPrivateProperty('parent value', 'child value'), + ), + ); + } + /** * @param array $value * @param non-negative-int $limit diff --git a/tests/_fixture/ChildClassWithPrivateProperty.php b/tests/_fixture/ChildClassWithPrivateProperty.php new file mode 100644 index 0000000..e384271 --- /dev/null +++ b/tests/_fixture/ChildClassWithPrivateProperty.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\Exporter; + +final class ChildClassWithPrivateProperty extends ParentClassWithPrivateProperty +{ + /** + * @phpstan-ignore property.onlyWritten + */ + private string $property; + + public function __construct(string $parentProperty, string $childProperty) + { + parent::__construct($parentProperty); + + $this->property = $childProperty; + } +} diff --git a/tests/_fixture/ParentClassWithPrivateProperty.php b/tests/_fixture/ParentClassWithPrivateProperty.php new file mode 100644 index 0000000..74588eb --- /dev/null +++ b/tests/_fixture/ParentClassWithPrivateProperty.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\Exporter; + +abstract class ParentClassWithPrivateProperty +{ + /** + * @phpstan-ignore property.onlyWritten + */ + private string $property; + + public function __construct(string $property) + { + $this->property = $property; + } +}