From b9ec7d93fec5bfde5b4271f8c328283a98063d67 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sat, 1 Aug 2026 08:49:42 +0200 Subject: [PATCH] Prevent duplicate array references and endless recursion when a custom object exporter exports nested values --- ChangeLog.md | 2 +- src/ExportContext.php | 82 ++++++++++++++ src/Exporter.php | 69 +++++++----- src/ObjectExporter.php | 7 +- src/ObjectExporterChain.php | 4 +- tests/ExporterTest.php | 101 ++++++++++++++++++ tests/ObjectExporterChainTest.php | 7 +- tests/_fixture/Node.php | 29 +++++ .../ObjectExporterThatExportsNestedValues.php | 31 ++++++ .../ObjectExporterThatHandlesEveryObject.php | 2 +- .../ObjectExporterThatHandlesNoObject.php | 2 +- ...porterThatHandlesObjectsOfSpecificType.php | 2 +- 12 files changed, 304 insertions(+), 34 deletions(-) create mode 100644 src/ExportContext.php create mode 100644 tests/_fixture/Node.php create mode 100644 tests/_fixture/ObjectExporterThatExportsNestedValues.php diff --git a/ChangeLog.md b/ChangeLog.md index 8592f26..6fe4149 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -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 diff --git a/src/ExportContext.php b/src/ExportContext.php new file mode 100644 index 0000000..9594d47 --- /dev/null +++ b/src/ExportContext.php @@ -0,0 +1,82 @@ + + * + * 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 + */ + 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); + } +} diff --git a/src/Exporter.php b/src/Exporter.php index cb08299..86611a0 100644 --- a/src/Exporter.php +++ b/src/Exporter.php @@ -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 $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"; } diff --git a/src/ObjectExporter.php b/src/ObjectExporter.php index 30c5f42..488f45c 100644 --- a/src/ObjectExporter.php +++ b/src/ObjectExporter.php @@ -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; } diff --git a/src/ObjectExporterChain.php b/src/ObjectExporterChain.php index ebe474a..c644385 100644 --- a/src/ObjectExporterChain.php +++ b/src/ObjectExporterChain.php @@ -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); } } diff --git a/tests/ExporterTest.php b/tests/ExporterTest.php index 46a2c9a..1d85854 100644 --- a/tests/ExporterTest.php +++ b/tests/ExporterTest.php @@ -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); diff --git a/tests/ObjectExporterChainTest.php b/tests/ObjectExporterChainTest.php index 1ab356c..079bed7 100644 --- a/tests/ObjectExporterChainTest.php +++ b/tests/ObjectExporterChainTest.php @@ -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); } } diff --git a/tests/_fixture/Node.php b/tests/_fixture/Node.php new file mode 100644 index 0000000..4ec4e2c --- /dev/null +++ b/tests/_fixture/Node.php @@ -0,0 +1,29 @@ + + * + * 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 + */ + public array $children; + + /** + * @param array $children + */ + public function __construct(array $children = []) + { + $this->children = $children; + } +} diff --git a/tests/_fixture/ObjectExporterThatExportsNestedValues.php b/tests/_fixture/ObjectExporterThatExportsNestedValues.php new file mode 100644 index 0000000..e460155 --- /dev/null +++ b/tests/_fixture/ObjectExporterThatExportsNestedValues.php @@ -0,0 +1,31 @@ + + * + * 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), + ); + } +} diff --git a/tests/_fixture/ObjectExporterThatHandlesEveryObject.php b/tests/_fixture/ObjectExporterThatHandlesEveryObject.php index ede777b..c915b6d 100644 --- a/tests/_fixture/ObjectExporterThatHandlesEveryObject.php +++ b/tests/_fixture/ObjectExporterThatHandlesEveryObject.php @@ -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)', diff --git a/tests/_fixture/ObjectExporterThatHandlesNoObject.php b/tests/_fixture/ObjectExporterThatHandlesNoObject.php index 61010ef..baba1e3 100644 --- a/tests/_fixture/ObjectExporterThatHandlesNoObject.php +++ b/tests/_fixture/ObjectExporterThatHandlesNoObject.php @@ -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; } diff --git a/tests/_fixture/ObjectExporterThatHandlesObjectsOfSpecificType.php b/tests/_fixture/ObjectExporterThatHandlesObjectsOfSpecificType.php index 887caac..4fff871 100644 --- a/tests/_fixture/ObjectExporterThatHandlesObjectsOfSpecificType.php +++ b/tests/_fixture/ObjectExporterThatHandlesObjectsOfSpecificType.php @@ -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',