This commit is contained in:
Sebastian Bergmann 2026-05-20 06:34:14 +02:00
parent 99dffe1f0c
commit d5f1896389
No known key found for this signature in database
5 changed files with 130 additions and 11 deletions

View File

@ -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

View File

@ -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<array-key, mixed> $properties
*
* @return array<string, true>
*/
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<mixed> $data
* @param positive-int $maxLengthForStrings

View File

@ -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<mixed> $value
* @param non-negative-int $limit

View File

@ -0,0 +1,25 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* 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;
}
}

View File

@ -0,0 +1,23 @@
<?php declare(strict_types=1);
/*
* This file is part of sebastian/exporter.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* 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;
}
}