Prevent duplicate array references and endless recursion when a custom object exporter exports nested values

This commit is contained in:
Sebastian Bergmann 2026-08-01 08:49:42 +02:00
parent 5f9dc382bf
commit b9ec7d93fe
No known key found for this signature in database
12 changed files with 304 additions and 34 deletions

View File

@ -6,7 +6,7 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](htt
### Added
`ObjectExporter` interface and `ObjectExporterChain` for customizing how objects are exported (a chain can be passed to `Exporter`'s constructor and is consulted before the default property-by-property export is applied)
`ObjectExporter` interface, `ObjectExporterChain`, and `ExportContext` for customizing how objects are exported (a chain can be passed to `Exporter`'s constructor and is consulted before the default property-by-property export is applied)
## [8.1.1] - 2026-07-13

82
src/ExportContext.php Normal file
View File

@ -0,0 +1,82 @@
<?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;
use SebastianBergmann\RecursionContext\Context as RecursionContext;
use SplObjectStorage;
/**
* Carries the state that is shared by all steps of a single export operation.
*
* An implementation of ObjectExporter that exports values nested in the object
* it handles must pass the instance of this class it is given to the Exporter
* it delegates to. Otherwise, the export of these nested values starts over
* with an empty context and, for instance, assigns references to arrays that
* are already in use elsewhere in the same export.
*/
final class ExportContext
{
private RecursionContext $recursionContext;
/**
* @var SplObjectStorage<object, null>
*/
private SplObjectStorage $exportedByObjectExporter;
public function __construct()
{
$this->recursionContext = new RecursionContext;
$this->exportedByObjectExporter = new SplObjectStorage;
}
/**
* @template T of array|object
*
* @param T $value
*
* @param-out T $value
*/
public function add(array|object &$value): int
{
return $this->recursionContext->add($value);
}
/**
* @template T of array|object
*
* @param T $value
*
* @param-out T $value
*/
public function contains(array|object &$value): false|int
{
return $this->recursionContext->contains($value);
}
/**
* Whether the export of an object by an ObjectExporter is in progress.
*
* This is the case when the object is (indirectly) nested in itself.
*/
public function isBeingExportedByObjectExporter(object $object): bool
{
return $this->exportedByObjectExporter->offsetExists($object);
}
public function beginExportByObjectExporter(object $object): void
{
$this->exportedByObjectExporter->offsetSet($object);
}
public function endExportByObjectExporter(object $object): void
{
$this->exportedByObjectExporter->offsetUnset($object);
}
}

View File

@ -86,10 +86,14 @@ final readonly class Exporter
* - Strings are always quoted with single quotes
* - Carriage returns and newlines are normalized to \n
* - Recursion and repeated rendering is treated properly
*
* An implementation of ObjectExporter must pass the ExportContext it is
* given to this method when it exports values that are nested in the
* object it handles.
*/
public function export(mixed $value, int $indentation = 0): string
public function export(mixed $value, int $indentation = 0, ?ExportContext $context = null): string
{
return $this->recursiveExport($value, $indentation);
return $this->recursiveExport($value, $indentation, $context);
}
/**
@ -350,7 +354,7 @@ final readonly class Exporter
return implode(', ', $result);
}
private function recursiveExport(mixed &$value, int $indentation = 0, ?RecursionContext $processed = null): string
private function recursiveExport(mixed &$value, int $indentation = 0, ?ExportContext $context = null): string
{
if ($value === null) {
return 'null';
@ -400,16 +404,16 @@ final readonly class Exporter
return $this->exportString($value);
}
if ($processed === null) {
$processed = new RecursionContext;
if ($context === null) {
$context = new ExportContext;
}
if (is_array($value)) {
return $this->exportArray($value, $processed, $indentation);
return $this->exportArray($value, $context, $indentation);
}
if (is_object($value)) {
return $this->exportObject($value, $processed, $indentation);
return $this->exportObject($value, $context, $indentation);
}
return var_export($value, true);
@ -486,14 +490,14 @@ final readonly class Exporter
/**
* @param array<mixed> $value
*/
private function exportArray(array &$value, RecursionContext $processed, int $indentation): string
private function exportArray(array &$value, ExportContext $context, int $indentation): string
{
if (($key = $processed->contains($value)) !== false) {
if (($key = $context->contains($value)) !== false) {
return 'Array &' . $key;
}
$array = $value;
$key = $processed->add($value);
$key = $context->add($value);
$values = '';
if (count($array) > 0) {
@ -506,7 +510,7 @@ final readonly class Exporter
$this->recursiveExport($k, $indentation)
. ' => ' .
/** @phpstan-ignore offsetAccess.invalidOffset */
$this->recursiveExport($value[$k], $indentation + 1, $processed)
$this->recursiveExport($value[$k], $indentation + 1, $context)
. ",\n";
}
@ -516,24 +520,41 @@ final readonly class Exporter
return 'Array &' . (string) $key . ' [' . $values . ']';
}
private function exportObject(object $value, RecursionContext $processed, int $indentation): string
private function exportObject(object $value, ExportContext $context, int $indentation): string
{
// A custom object exporter is responsible for the entire
// representation of the object it handles. Therefore, it is asked for
// that representation before the recursion context is consulted: every
// occurrence of such an object is exported the same way instead of
// repeated occurrences being replaced with a reference to the object.
if ($this->objectExporter !== null && $this->objectExporter->handles($value)) {
return $this->objectExporter->export($value, $this, $indentation);
}
$class = $value::class;
if ($processed->contains($value) !== false) {
if ($this->objectExporter !== null) {
// An object that is (indirectly) nested in itself cannot be
// exported by a custom object exporter without recursing
// infinitely and is therefore replaced with a reference to the
// object.
if ($context->isBeingExportedByObjectExporter($value)) {
return $class . ' Object #' . spl_object_id($value);
}
// A custom object exporter is responsible for the entire
// representation of the object it handles. Therefore, it is asked
// for that representation before the recursion context is
// consulted: every occurrence of such an object is exported the
// same way instead of repeated occurrences being replaced with a
// reference to the object.
if ($this->objectExporter->handles($value)) {
$context->beginExportByObjectExporter($value);
try {
return $this->objectExporter->export($value, $this, $indentation, $context);
} finally {
$context->endExportByObjectExporter($value);
}
}
}
if ($context->contains($value) !== false) {
return $class . ' Object #' . spl_object_id($value);
}
$processed->add($value);
$context->add($value);
$array = $this->toArray($value);
$buffer = '';
@ -547,7 +568,7 @@ final readonly class Exporter
. ' ' .
$this->recursiveExport($k, $indentation)
. ' => ' .
$this->recursiveExport($v, $indentation + 1, $processed)
$this->recursiveExport($v, $indentation + 1, $context)
. ",\n";
}

View File

@ -14,7 +14,12 @@ interface ObjectExporter
public function handles(object $object): bool;
/**
* Exports an object this object exporter handles.
*
* The ExportContext must be passed on to Exporter::export() when values
* that are nested in the object are exported using $exporter.
*
* @throws ObjectNotSupportedException
*/
public function export(object $object, Exporter $exporter, int $indentation): string;
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string;
}

View File

@ -37,11 +37,11 @@ final readonly class ObjectExporterChain implements ObjectExporter
/**
* @throws ObjectNotSupportedException
*/
public function export(object $object, Exporter $exporter, int $indentation): string
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
foreach ($this->exporters as $objectExporter) {
if ($objectExporter->handles($object)) {
return $objectExporter->export($object, $exporter, $indentation);
return $objectExporter->export($object, $exporter, $indentation, $context);
}
}

View File

@ -38,6 +38,7 @@ use SplObjectStorage;
use stdClass;
#[CoversClass(Exporter::class)]
#[UsesClass(ExportContext::class)]
#[UsesClass(ObjectExporterChain::class)]
#[Small]
final class ExporterTest extends TestCase
@ -784,6 +785,106 @@ EOT,
);
}
public function testCustomObjectExporterCanExportValuesNestedInObjectItHandles(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatExportsNestedValues,
],
),
);
$this->assertSame(
<<<'EOT'
Node(Array &0 [
'key' => 'value',
])
EOT,
$exporter->export(new Node(['key' => 'value'])),
);
}
public function testArrayExportedByCustomObjectExporterDoesNotReuseReferenceToOtherArray(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatExportsNestedValues,
],
),
);
$this->assertSame(
<<<'EOT'
Array &0 [
0 => Node(Array &1 [
'key' => 'value',
]),
]
EOT,
$exporter->export([new Node(['key' => 'value'])]),
);
}
public function testObjectNestedInItselfIsNotExportedByCustomObjectExporterAgain(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatExportsNestedValues,
],
),
);
$node = new Node;
$node->children = [$node];
$this->assertStringMatchesFormat(
<<<'EOT'
Node(Array &0 [
0 => SebastianBergmann\Exporter\Node Object #%d,
])
EOT,
$exporter->export($node),
);
}
public function testObjectsNestedInEachOtherAreNotExportedByCustomObjectExporterAgain(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatExportsNestedValues,
],
),
);
$first = new Node;
$second = new Node;
$first->children = [$second];
$second->children = [$first];
$this->assertStringMatchesFormat(
<<<'EOT'
Node(Array &0 [
0 => Node(Array &1 [
0 => SebastianBergmann\Exporter\Node Object #%d,
]),
])
EOT,
$exporter->export($first),
);
}
private function trimNewline(string $string): string
{
return (string) preg_replace('/[ ]*\n/', "\n", $string);

View File

@ -17,6 +17,7 @@ use stdClass;
#[CoversClass(ObjectExporterChain::class)]
#[UsesClass(Exporter::class)]
#[UsesClass(ExportContext::class)]
#[Small]
final class ObjectExporterChainTest extends TestCase
{
@ -55,12 +56,12 @@ final class ObjectExporterChainTest extends TestCase
$this->assertSame(
'stdClass handled by custom exporter',
$chain->export(new stdClass, new Exporter, 0),
$chain->export(new stdClass, new Exporter, 0, new ExportContext),
);
$this->assertSame(
ExampleClass::class . ' (indentation: 0)',
$chain->export(new ExampleClass('foo'), new Exporter, 0),
$chain->export(new ExampleClass('foo'), new Exporter, 0, new ExportContext),
);
}
@ -74,6 +75,6 @@ final class ObjectExporterChainTest extends TestCase
$this->expectException(ObjectNotSupportedException::class);
$chain->export(new stdClass, new Exporter, 0);
$chain->export(new stdClass, new Exporter, 0, new ExportContext);
}
}

29
tests/_fixture/Node.php Normal file
View File

@ -0,0 +1,29 @@
<?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;
/*
* Helper to test export of objects that are nested in objects.
*/
final class Node
{
/**
* @var array<mixed>
*/
public array $children;
/**
* @param array<mixed> $children
*/
public function __construct(array $children = [])
{
$this->children = $children;
}
}

View File

@ -0,0 +1,31 @@
<?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;
use function assert;
use function sprintf;
final readonly class ObjectExporterThatExportsNestedValues implements ObjectExporter
{
public function handles(object $object): bool
{
return $object instanceof Node;
}
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
assert($object instanceof Node);
return sprintf(
'Node(%s)',
$exporter->export($object->children, $indentation, $context),
);
}
}

View File

@ -18,7 +18,7 @@ final readonly class ObjectExporterThatHandlesEveryObject implements ObjectExpor
return true;
}
public function export(object $object, Exporter $exporter, int $indentation): string
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
return sprintf(
'%s (indentation: %d)',

View File

@ -16,7 +16,7 @@ final readonly class ObjectExporterThatHandlesNoObject implements ObjectExporter
return false;
}
public function export(object $object, Exporter $exporter, int $indentation): string
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
throw new ObjectNotSupportedException;
}

View File

@ -31,7 +31,7 @@ final readonly class ObjectExporterThatHandlesObjectsOfSpecificType implements O
return $object instanceof $this->type;
}
public function export(object $object, Exporter $exporter, int $indentation): string
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
{
return sprintf(
'%s handled by custom exporter',