Consult custom object exporter when exporting a value into a single-line string

This commit is contained in:
Sebastian Bergmann 2026-08-01 09:25:16 +02:00
parent 60d658c5b5
commit 1ff285c572
No known key found for this signature in database
5 changed files with 179 additions and 6 deletions

View File

@ -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

View File

@ -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
<?php declare(strict_types=1);
use SebastianBergmann\Exporter\Exporter;
$exporter = new Exporter(objectExporter: new MoneyExporter);
// Money (1999 EUR)
print $exporter->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.

View File

@ -101,6 +101,8 @@ final readonly class Exporter
/**
* @param array<mixed> $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));
if (mb_strlen($string) > $maxLengthForStrings) {
return mb_substr($string, 0, $maxLengthForStrings - 10) . '...' . mb_substr($string, -7);
return $this->shorten($this->exportString($value), $maxLengthForStrings);
}
return $string;
if ($this->objectExporter !== null &&
is_object($value) &&
$this->objectExporter->handles($value)) {
$context = new ExportContext;
$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<mixed> $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)) {

View File

@ -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(

View File

@ -0,0 +1,30 @@
<?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;
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;
}
}