From 1ff285c572780c1c80dbafeaf6fbdeab6b097850 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sat, 1 Aug 2026 09:25:16 +0200 Subject: [PATCH] Consult custom object exporter when exporting a value into a single-line string --- ChangeLog.md | 1 + README.md | 16 +++- src/Exporter.php | 44 ++++++++- tests/ExporterTest.php | 94 +++++++++++++++++++ ...ExporterThatReturnsGivenRepresentation.php | 30 ++++++ 5 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 tests/_fixture/ObjectExporterThatReturnsGivenRepresentation.php diff --git a/ChangeLog.md b/ChangeLog.md index 0127039..6456082 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -7,6 +7,7 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](htt ### Added * `ObjectExporter` interface, `ObjectExporterChain`, and `ExportContext` for customizing how objects are exported (an object exporter can be passed to `Exporter`'s constructor and is consulted before the default representation of an object is used) +* `Exporter::shortenedExport()` and `Exporter::shortenedRecursiveExport()` use the representation provided by an object exporter as well; it is collapsed to a single line and shortened when it is longer than the configured maximum length ## [8.1.1] - 2026-07-13 diff --git a/README.md b/README.md index adbfb04..b353939 100644 --- a/README.md +++ b/README.md @@ -319,4 +319,18 @@ Array &0 [ print $exporter->export(['basket' => new Basket([new Money(1999, 'EUR')])]); ``` -Object exporters are not consulted by `Exporter::shortenedExport()` and `Exporter::shortenedRecursiveExport()`, which deliberately elide the contents of objects. +### Compact Exports + +`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: + +```php +shortenedExport(new Money(1999, 'EUR')); +``` + +Bear in mind that these representations are used where a short, single-line, and stable string is required. The representation of a data set is built using `Exporter::shortenedRecursiveExport()`, for instance, and is part of the name of a test that uses a data provider. An object exporter should therefore provide a representation that is compact and that does not change from one export to the next. diff --git a/src/Exporter.php b/src/Exporter.php index 47c6788..9a6306d 100644 --- a/src/Exporter.php +++ b/src/Exporter.php @@ -101,6 +101,8 @@ final readonly class Exporter /** * @param array $data * @param positive-int $maxLengthForStrings + * + * @throws ObjectNotSupportedException */ public function shortenedRecursiveExport(array &$data, int $maxLengthForStrings = 40, ?RecursionContext $processed = null): string { @@ -134,7 +136,13 @@ final readonly class Exporter * Newlines are replaced by the visible string '\n'. * Contents of arrays and objects (if any) are replaced by '...'. * + * The representation a custom object exporter provides for an object is + * used, but it is collapsed to a single line and shortened when it is + * longer than $maxLengthForStrings. + * * @param positive-int $maxLengthForStrings + * + * @throws ObjectNotSupportedException */ public function shortenedExport(mixed $value, int $maxLengthForStrings = 40): string { @@ -143,13 +151,20 @@ final readonly class Exporter } if (is_string($value)) { - $string = str_replace("\n", '', $this->exportString($value)); + return $this->shorten($this->exportString($value), $maxLengthForStrings); + } - if (mb_strlen($string) > $maxLengthForStrings) { - return mb_substr($string, 0, $maxLengthForStrings - 10) . '...' . mb_substr($string, -7); - } + if ($this->objectExporter !== null && + is_object($value) && + $this->objectExporter->handles($value)) { + $context = new ExportContext; - return $string; + $context->beginExportByObjectExporter($value); + + return $this->shorten( + $this->objectExporter->export($value, $this, 0, $context), + $maxLengthForStrings, + ); } if ($value instanceof BackedEnum) { @@ -320,6 +335,8 @@ final readonly class Exporter /** * @param array $data * @param positive-int $maxLengthForStrings + * + * @throws ObjectNotSupportedException */ private function shortenedCountedRecursiveExport(array &$data, RecursionContext $processed, int &$counter, int $maxLengthForStrings): string { @@ -405,6 +422,23 @@ final readonly class Exporter return var_export($value, true); } + /** + * Collapses a representation to a single line and shortens it when it is + * longer than $maxLengthForStrings. + * + * @param positive-int $maxLengthForStrings + */ + private function shorten(string $string, int $maxLengthForStrings): string + { + $string = str_replace(["\r", "\n"], '', $string); + + if (mb_strlen($string) > $maxLengthForStrings) { + return mb_substr($string, 0, $maxLengthForStrings - 10) . '...' . mb_substr($string, -7); + } + + return $string; + } + private function exportFloat(float $value): string { if (is_nan($value)) { diff --git a/tests/ExporterTest.php b/tests/ExporterTest.php index 8e5b8ef..8720e5d 100644 --- a/tests/ExporterTest.php +++ b/tests/ExporterTest.php @@ -845,6 +845,100 @@ EOT, ); } + public function testShortenedExportUsesRepresentationProvidedByCustomObjectExporter(): void + { + $exporter = new Exporter( + 0, + 40, + new ObjectExporterThatHandlesEveryObject, + ); + + $this->assertSame( + 'stdClass (indentation: 0)', + $exporter->shortenedExport(new stdClass), + ); + + $this->assertSame( + 'SebastianBergmann\Exporter\ExampleEnum (indentation: 0)', + $exporter->shortenedExport(ExampleEnum::Value, 80), + ); + } + + public function testShortenedExportCollapsesRepresentationProvidedByCustomObjectExporterToSingleLine(): void + { + $exporter = new Exporter( + 0, + 40, + new ObjectExporterThatReturnsGivenRepresentation("first\nsecond"), + ); + + $this->assertSame( + 'firstsecond', + $exporter->shortenedExport(new stdClass), + ); + } + + public function testShortenedExportShortensRepresentationProvidedByCustomObjectExporter(): void + { + $exporter = new Exporter( + 0, + 40, + new ObjectExporterThatReturnsGivenRepresentation(str_repeat('a', 41)), + ); + + $this->assertSame( + str_repeat('a', 30) . '...' . str_repeat('a', 7), + $exporter->shortenedExport(new stdClass), + ); + } + + public function testShortenedRecursiveExportUsesRepresentationProvidedByCustomObjectExporter(): void + { + $exporter = new Exporter( + 0, + 40, + new ObjectExporterThatHandlesEveryObject, + ); + + $data = [new stdClass, 'foo']; + + $this->assertSame( + "stdClass (indentation: 0), 'foo'", + $exporter->shortenedRecursiveExport($data), + ); + } + + public function testShortenedExportDoesNotRecurseInfinitelyForObjectNestedInItself(): void + { + $exporter = new Exporter( + 0, + 40, + new ObjectExporterThatExportsNestedValues, + ); + + $node = new Node; + $node->children = [$node]; + + $this->assertStringMatchesFormat( + 'Node(Array &0 [ 0 => SebastianBergmann\Exporter\Node Object #%d,])', + $exporter->shortenedExport($node, 200), + ); + } + + public function testShortenedExportUsesDefaultRepresentationWhenNoCustomObjectExporterHandlesObject(): void + { + $exporter = new Exporter( + 0, + 40, + new ObjectExporterThatHandlesNoObject, + ); + + $this->assertSame( + 'stdClass Object ()', + $exporter->shortenedExport(new stdClass), + ); + } + public function testCustomObjectExporterCanExportValuesNestedInObjectItHandles(): void { $exporter = new Exporter( diff --git a/tests/_fixture/ObjectExporterThatReturnsGivenRepresentation.php b/tests/_fixture/ObjectExporterThatReturnsGivenRepresentation.php new file mode 100644 index 0000000..51600e5 --- /dev/null +++ b/tests/_fixture/ObjectExporterThatReturnsGivenRepresentation.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\Exporter; + +final readonly class ObjectExporterThatReturnsGivenRepresentation implements ObjectExporter +{ + private string $representation; + + public function __construct(string $representation) + { + $this->representation = $representation; + } + + public function handles(object $object): bool + { + return true; + } + + public function export(object $object, Exporter $exporter, int $indentation, ExportContext $context): string + { + return $this->representation; + } +}