Prevent duplicate array references and endless recursion when a custom object exporter exports nested values
This commit is contained in:
parent
5f9dc382bf
commit
b9ec7d93fe
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue