Skip to content

Commit 5b87809

Browse files
Closes #202
1 parent 5feb913 commit 5b87809

10 files changed

Lines changed: 313 additions & 7 deletions

‎.github/workflows/ci.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ concurrency:
1111
cancel-in-progress: true
1212

1313
env:
14-
COMPOSER_ROOT_VERSION: 8.2.x-dev
14+
COMPOSER_ROOT_VERSION: 8.3.x-dev
1515
PHP_VERSION: 8.5
1616

1717
permissions:

‎ChangeLog.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes are documented in this file using the [Keep a CHANGELOG](https://keepachangelog.com/) principles.
44

5+
## [8.3.0] - 2026-MM-DD
6+
7+
### Added
8+
9+
* [#202](https://github.com/sebastianbergmann/exporter/issues/202): `ObjectReferenceExporter` interface that an object exporter can implement to provide the representation of the reference that replaces an object it handles when that object is (indirectly) nested in itself
10+
511
## [8.2.1] - 2026-08-07
612

713
### Fixed
@@ -75,6 +81,7 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](htt
7581

7682
* This component is no longer supported on PHP 8.2
7783

84+
[8.3.0]: https://github.com/sebastianbergmann/exporter/compare/8.2.1...main
7885
[8.2.1]: https://github.com/sebastianbergmann/exporter/compare/8.2.0...8.2.1
7986
[8.2.0]: https://github.com/sebastianbergmann/exporter/compare/8.1.1...8.2.0
8087
[8.1.1]: https://github.com/sebastianbergmann/exporter/compare/8.1.0...8.1.1

‎README.md‎

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ Array &0 [
279279
print $exporter->export([$money, $money]);
280280
```
281281

282-
The exception is an object that is (indirectly) nested in itself: it cannot be exported this way without recursing infinitely and is replaced with a reference to the object.
282+
The exception is an object that is (indirectly) nested in itself: it cannot be exported this way without recursing infinitely and is replaced with a reference to the object (see [Objects Nested in Themselves](#objects-nested-in-themselves)).
283283

284284
### Exporting Nested Values
285285

@@ -319,6 +319,77 @@ Array &0 [
319319
print $exporter->export(['basket' => new Basket([new Money(1999, 'EUR')])]);
320320
```
321321

322+
### Objects Nested in Themselves
323+
324+
An object that is (indirectly) nested in itself cannot be exported by an object exporter without recursing infinitely. When such an object occurs again while it is being exported by an object exporter, it is replaced with a reference to the object:
325+
326+
```php
327+
/*
328+
Person (Alice) Array &0 [
329+
0 => Person (Bob) Array &1 [
330+
0 => Person Object #2,
331+
],
332+
]
333+
*/
334+
335+
$alice = new Person('Alice');
336+
$bob = new Person('Bob');
337+
338+
$alice->befriend($bob);
339+
340+
print $exporter->export($alice);
341+
```
342+
343+
An object exporter can provide the representation of this reference by also implementing the `ObjectReferenceExporter` interface. When its `exportReference()` method returns `null`, the default representation of the reference is used:
344+
345+
```php
346+
<?php declare(strict_types=1);
347+
use SebastianBergmann\Exporter\ExportContext;
348+
use SebastianBergmann\Exporter\Exporter;
349+
use SebastianBergmann\Exporter\ObjectExporter;
350+
use SebastianBergmann\Exporter\ObjectReferenceExporter;
351+
352+
final class PersonExporter implements ObjectExporter, ObjectReferenceExporter
353+
{
354+
public function handles(object $object): bool
355+
{
356+
return $object instanceof Person;
357+
}
358+
359+
public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string
360+
{
361+
assert($object instanceof Person);
362+
363+
return sprintf(
364+
'Person (%s) %s',
365+
$object->name(),
366+
$exporter->export($object->friends(), $indentation, $context),
367+
);
368+
}
369+
370+
public function exportReference(object $object): ?string
371+
{
372+
assert($object instanceof Person);
373+
374+
return sprintf('Person (%s)', $object->name());
375+
}
376+
}
377+
```
378+
379+
```php
380+
/*
381+
Person (Alice) Array &0 [
382+
0 => Person (Bob) Array &1 [
383+
0 => Person (Alice),
384+
],
385+
]
386+
*/
387+
388+
print $exporter->export($alice);
389+
```
390+
391+
The object exporter is only asked for the representation of the reference, it is not given the opportunity to export the object again. When object exporters are composed into an `ObjectExporterChain`, the reference is provided by the object exporter that exports the object, which is the first one whose `handles()` method returns `true`.
392+
322393
### Compact Exports
323394

324395
`Exporter::shortenedExport()` and `Exporter::shortenedRecursiveExport()` consult object exporters as well. The representation an object exporter provides is collapsed to a single line and shortened when it is longer than the configured maximum length:
@@ -352,4 +423,4 @@ var_dump($exporter->hasCustomRepresentationFor(new Money(1999, 'EUR')));
352423
var_dump($exporter->hasCustomRepresentationFor(new stdClass));
353424
```
354425

355-
Bear in mind that an object that is (indirectly) nested in itself is replaced with a reference to the object instead of being exported by an object exporter again.
426+
Bear in mind that an object that is (indirectly) nested in itself is replaced with a reference to the object instead of being exported by an object exporter again. An object exporter that implements `ObjectReferenceExporter` can provide the representation of this reference.

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
},
5959
"extra": {
6060
"branch-alias": {
61-
"dev-main": "8.2-dev"
61+
"dev-main": "8.3-dev"
6262
}
6363
}
6464
}

‎src/Exporter.php‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,9 @@ public function shortenedExport(mixed $value, int $maxLengthForStrings = 40): st
211211
*
212212
* Bear in mind that an object that is (indirectly) nested in itself is
213213
* replaced with a reference to the object instead of being exported by a
214-
* custom object exporter again.
214+
* custom object exporter again. A custom object exporter that implements
215+
* ObjectReferenceExporter may provide the representation of this
216+
* reference.
215217
*/
216218
public function hasCustomRepresentationFor(object $object): bool
217219
{
@@ -573,8 +575,18 @@ private function exportObject(object $value, ExportContext $context, int $indent
573575
// An object that is (indirectly) nested in itself cannot be
574576
// exported by a custom object exporter without recursing
575577
// infinitely and is therefore replaced with a reference to the
576-
// object.
578+
// object. A custom object exporter may provide the representation
579+
// of this reference, but it is not given the opportunity to
580+
// export the object again.
577581
if ($context->isBeingExportedByObjectExporter($value)) {
582+
if ($this->objectExporter instanceof ObjectReferenceExporter) {
583+
$reference = $this->objectExporter->exportReference($value);
584+
585+
if ($reference !== null) {
586+
return $reference;
587+
}
588+
}
589+
578590
return $class . ' Object #' . spl_object_id($value);
579591
}
580592

‎src/ObjectExporterChain.php‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
use function array_any;
1313

14-
final readonly class ObjectExporterChain implements ObjectExporter
14+
final readonly class ObjectExporterChain implements ObjectExporter, ObjectReferenceExporter
1515
{
1616
/**
1717
* @var non-empty-list<ObjectExporter>
@@ -47,4 +47,27 @@ public function export(object $object, Exporter $exporter, int $indentation, Exp
4747

4848
throw new ObjectNotSupportedException;
4949
}
50+
51+
/**
52+
* Delegates to the object exporter that exports the object, which is the
53+
* first one that handles it. The default representation of the reference
54+
* is used when that object exporter does not implement
55+
* ObjectReferenceExporter.
56+
*/
57+
public function exportReference(object $object): ?string
58+
{
59+
foreach ($this->exporters as $objectExporter) {
60+
if (!$objectExporter->handles($object)) {
61+
continue;
62+
}
63+
64+
if ($objectExporter instanceof ObjectReferenceExporter) {
65+
return $objectExporter->exportReference($object);
66+
}
67+
68+
return null;
69+
}
70+
71+
return null;
72+
}
5073
}

‎src/ObjectReferenceExporter.php‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php declare(strict_types=1);
2+
/*
3+
* This file is part of sebastian/exporter.
4+
*
5+
* (c) Sebastian Bergmann <sebastian@phpunit.de>
6+
*
7+
* For the full copyright and license information, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*/
10+
namespace SebastianBergmann\Exporter;
11+
12+
/**
13+
* Can be implemented by an ObjectExporter that wants to provide the
14+
* representation of the reference that replaces an object it handles when
15+
* that object is (indirectly) nested in itself.
16+
*/
17+
interface ObjectReferenceExporter
18+
{
19+
/**
20+
* Exports a reference to an object that is (indirectly) nested in itself.
21+
*
22+
* Returns null when the default representation of the reference is to be
23+
* used.
24+
*/
25+
public function exportReference(object $object): ?string;
26+
}

‎tests/ExporterTest.php‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,94 @@ public function testObjectsNestedInEachOtherAreNotExportedByCustomObjectExporter
10741074
);
10751075
}
10761076

1077+
public function testObjectNestedInItselfIsReplacedWithReferenceProvidedByCustomObjectExporter(): void
1078+
{
1079+
$exporter = new Exporter(
1080+
0,
1081+
40,
1082+
new ObjectExporterThatExportsNestedValuesAndReferences('Node (reference)'),
1083+
);
1084+
1085+
$node = new Node;
1086+
$node->children = [$node];
1087+
1088+
$this->assertSame(
1089+
<<<'EOT'
1090+
Node(Array &0 [
1091+
0 => Node (reference),
1092+
])
1093+
EOT,
1094+
$exporter->export($node),
1095+
);
1096+
}
1097+
1098+
public function testObjectsNestedInEachOtherAreReplacedWithReferenceProvidedByCustomObjectExporterComposedIntoChain(): void
1099+
{
1100+
$exporter = new Exporter(
1101+
0,
1102+
40,
1103+
new ObjectExporterChain(
1104+
[
1105+
new ObjectExporterThatExportsNestedValuesAndReferences('Node (reference)'),
1106+
],
1107+
),
1108+
);
1109+
1110+
$first = new Node;
1111+
$second = new Node;
1112+
$first->children = [$second];
1113+
$second->children = [$first];
1114+
1115+
$this->assertSame(
1116+
<<<'EOT'
1117+
Node(Array &0 [
1118+
0 => Node(Array &1 [
1119+
0 => Node (reference),
1120+
]),
1121+
])
1122+
EOT,
1123+
$exporter->export($first),
1124+
);
1125+
}
1126+
1127+
public function testObjectNestedInItselfIsReplacedWithDefaultReferenceWhenCustomObjectExporterDoesNotProvideReference(): void
1128+
{
1129+
$exporter = new Exporter(
1130+
0,
1131+
40,
1132+
new ObjectExporterThatExportsNestedValuesAndReferences(null),
1133+
);
1134+
1135+
$node = new Node;
1136+
$node->children = [$node];
1137+
1138+
$this->assertStringMatchesFormat(
1139+
<<<'EOT'
1140+
Node(Array &0 [
1141+
0 => SebastianBergmann\Exporter\Node Object #%d,
1142+
])
1143+
EOT,
1144+
$exporter->export($node),
1145+
);
1146+
}
1147+
1148+
public function testShortenedExportUsesReferenceProvidedByCustomObjectExporterForObjectNestedInItself(): void
1149+
{
1150+
$exporter = new Exporter(
1151+
0,
1152+
40,
1153+
new ObjectExporterThatExportsNestedValuesAndReferences('Node (reference)'),
1154+
);
1155+
1156+
$node = new Node;
1157+
$node->children = [$node];
1158+
1159+
$this->assertSame(
1160+
'Node(Array &0 [ 0 => Node (reference),])',
1161+
$exporter->shortenedExport($node, 200),
1162+
);
1163+
}
1164+
10771165
private function trimNewline(string $string): string
10781166
{
10791167
return (string) preg_replace('/[ ]*\n/', "\n", $string);

‎tests/ObjectExporterChainTest.php‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,40 @@ public function testCannotExportObjectWhenNoComposedObjectExporterHandlesIt(): v
7777

7878
$chain->export(new stdClass, new Exporter, 0, new ExportContext);
7979
}
80+
81+
public function testDelegatesExportingReferenceToFirstComposedObjectExporterThatHandlesObject(): void
82+
{
83+
$chain = new ObjectExporterChain(
84+
[
85+
new ObjectExporterThatHandlesNoObject,
86+
new ObjectExporterThatExportsNestedValuesAndReferences('first'),
87+
new ObjectExporterThatExportsNestedValuesAndReferences('second'),
88+
],
89+
);
90+
91+
$this->assertSame('first', $chain->exportReference(new Node));
92+
}
93+
94+
public function testDoesNotExportReferenceWhenFirstComposedObjectExporterThatHandlesObjectDoesNotExportReferences(): void
95+
{
96+
$chain = new ObjectExporterChain(
97+
[
98+
new ObjectExporterThatHandlesEveryObject,
99+
new ObjectExporterThatExportsNestedValuesAndReferences('reference'),
100+
],
101+
);
102+
103+
$this->assertNull($chain->exportReference(new Node));
104+
}
105+
106+
public function testDoesNotExportReferenceWhenNoComposedObjectExporterHandlesObject(): void
107+
{
108+
$chain = new ObjectExporterChain(
109+
[
110+
new ObjectExporterThatHandlesNoObject,
111+
],
112+
);
113+
114+
$this->assertNull($chain->exportReference(new stdClass));
115+
}
80116
}

0 commit comments

Comments
 (0)