Introduce ObjectExporter interface and ObjectExporterChain for customizing how objects are exported

This commit is contained in:
Sebastian Bergmann 2026-07-13 13:48:57 +02:00
parent cfaa77c750
commit 3f33ba4c67
No known key found for this signature in database
13 changed files with 402 additions and 4 deletions

View File

@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
env: env:
COMPOSER_ROOT_VERSION: 8.1.x-dev COMPOSER_ROOT_VERSION: 8.2.x-dev
PHP_VERSION: 8.4 PHP_VERSION: 8.4
permissions: permissions:

View File

@ -2,6 +2,12 @@
All notable changes are documented in this file using the [Keep a CHANGELOG](https://keepachangelog.com/) principles. All notable changes are documented in this file using the [Keep a CHANGELOG](https://keepachangelog.com/) principles.
## [8.2.0] - 2026-08-07
### 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)
## [8.1.1] - 2026-07-13 ## [8.1.1] - 2026-07-13
### Fixed ### Fixed
@ -61,6 +67,7 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](htt
* This component is no longer supported on PHP 8.2 * This component is no longer supported on PHP 8.2
[8.2.0]: https://github.com/sebastianbergmann/exporter/compare/8.1.1...main
[8.1.1]: https://github.com/sebastianbergmann/exporter/compare/8.1.0...8.1.1 [8.1.1]: https://github.com/sebastianbergmann/exporter/compare/8.1.0...8.1.1
[8.1.0]: https://github.com/sebastianbergmann/exporter/compare/8.0.3...8.1.0 [8.1.0]: https://github.com/sebastianbergmann/exporter/compare/8.0.3...8.1.0
[8.0.3]: https://github.com/sebastianbergmann/exporter/compare/8.0.2...8.0.3 [8.0.3]: https://github.com/sebastianbergmann/exporter/compare/8.0.2...8.0.3

View File

@ -37,6 +37,7 @@
"optimize-autoloader": true, "optimize-autoloader": true,
"sort-packages": true "sort-packages": true
}, },
"minimum-stability": "dev",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.4", "php": ">=8.4",
@ -44,7 +45,7 @@
"sebastian/recursion-context": "^8.0" "sebastian/recursion-context": "^8.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^13.2.4" "phpunit/phpunit": "^13.3"
}, },
"autoload": { "autoload": {
"classmap": [ "classmap": [
@ -58,7 +59,7 @@
}, },
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-main": "8.1-dev" "dev-main": "8.2-dev"
} }
} }
} }

View File

@ -61,15 +61,17 @@ final readonly class Exporter
* @var positive-int * @var positive-int
*/ */
private int $maxLengthForStrings; private int $maxLengthForStrings;
private ?ObjectExporterChain $objectExporter;
/** /**
* @param non-negative-int $shortenArraysLongerThan * @param non-negative-int $shortenArraysLongerThan
* @param positive-int $maxLengthForStrings * @param positive-int $maxLengthForStrings
*/ */
public function __construct(int $shortenArraysLongerThan = 0, int $maxLengthForStrings = 40) public function __construct(int $shortenArraysLongerThan = 0, int $maxLengthForStrings = 40, ?ObjectExporterChain $objectExporter = null)
{ {
$this->shortenArraysLongerThan = $shortenArraysLongerThan; $this->shortenArraysLongerThan = $shortenArraysLongerThan;
$this->maxLengthForStrings = $maxLengthForStrings; $this->maxLengthForStrings = $maxLengthForStrings;
$this->objectExporter = $objectExporter;
} }
/** /**
@ -524,6 +526,10 @@ final readonly class Exporter
$processed->add($value); $processed->add($value);
if ($this->objectExporter !== null && $this->objectExporter->handles($value)) {
return $this->objectExporter->export($value, $this, $indentation);
}
$array = $this->toArray($value); $array = $this->toArray($value);
$buffer = ''; $buffer = '';

20
src/ObjectExporter.php Normal file
View File

@ -0,0 +1,20 @@
<?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;
interface ObjectExporter
{
public function handles(object $object): bool;
/**
* @throws ObjectNotSupportedException
*/
public function export(object $object, Exporter $exporter, int $indentation): string;
}

View File

@ -0,0 +1,50 @@
<?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 array_any;
final readonly class ObjectExporterChain implements ObjectExporter
{
/**
* @var non-empty-list<ObjectExporter>
*/
private array $exporters;
/**
* @param non-empty-list<ObjectExporter> $exporters
*/
public function __construct(array $exporters)
{
$this->exporters = $exporters;
}
public function handles(object $object): bool
{
return array_any(
$this->exporters,
static fn (ObjectExporter $exporter) => $exporter->handles($object),
);
}
/**
* @throws ObjectNotSupportedException
*/
public function export(object $object, Exporter $exporter, int $indentation): string
{
foreach ($this->exporters as $objectExporter) {
if ($objectExporter->handles($object)) {
return $objectExporter->export($object, $exporter, $indentation);
}
}
throw new ObjectNotSupportedException;
}
}

View File

@ -0,0 +1,16 @@
<?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 Throwable;
interface Exception extends Throwable
{
}

View File

@ -0,0 +1,16 @@
<?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 RuntimeException;
final class ObjectNotSupportedException extends RuntimeException implements Exception
{
}

View File

@ -30,6 +30,7 @@ use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use ReflectionClass; use ReflectionClass;
use SebastianBergmann\RecursionContext\Context; use SebastianBergmann\RecursionContext\Context;
@ -37,6 +38,7 @@ use SplObjectStorage;
use stdClass; use stdClass;
#[CoversClass(Exporter::class)] #[CoversClass(Exporter::class)]
#[UsesClass(ObjectExporterChain::class)]
#[Small] #[Small]
final class ExporterTest extends TestCase final class ExporterTest extends TestCase
{ {
@ -649,6 +651,114 @@ EOF;
$this->assertTrue($reflector->isUninitializedLazyObject($object)); $this->assertTrue($reflector->isUninitializedLazyObject($object));
} }
public function testObjectCanBeExportedByCustomObjectExporter(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesEveryObject,
],
),
);
$this->assertSame(
'stdClass (indentation: 0)',
$exporter->export(new stdClass),
);
}
public function testObjectNestedInArrayCanBeExportedByCustomObjectExporter(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesEveryObject,
],
),
);
$this->assertSame(
<<<'EOT'
Array &0 [
0 => stdClass (indentation: 1),
]
EOT,
$exporter->export([new stdClass]),
);
}
public function testObjectNestedInObjectCanBeExportedByCustomObjectExporter(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesObjectsOfSpecificType(ExampleClass::class),
],
),
);
$object = new stdClass;
$object->nested = new ExampleClass('bar');
$this->assertStringMatchesFormat(
<<<'EOT'
stdClass Object #%d (
'nested' => SebastianBergmann\Exporter\ExampleClass handled by custom exporter,
)
EOT,
$exporter->export($object),
);
}
public function testObjectHandledByCustomObjectExporterIsOnlyExportedOnce(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesEveryObject,
],
),
);
$object = new stdClass;
$this->assertStringMatchesFormat(
<<<'EOT'
Array &0 [
0 => stdClass (indentation: 1),
1 => stdClass Object #%d,
]
EOT,
$exporter->export([$object, $object]),
);
}
public function testDefaultExportIsUsedWhenNoCustomObjectExporterHandlesObject(): void
{
$exporter = new Exporter(
0,
40,
new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
],
),
);
$this->assertStringMatchesFormat(
'stdClass Object #%d ()',
$exporter->export(new stdClass),
);
}
private function trimNewline(string $string): string private function trimNewline(string $string): string
{ {
return (string) preg_replace('/[ ]*\n/', "\n", $string); return (string) preg_replace('/[ ]*\n/', "\n", $string);

View File

@ -0,0 +1,79 @@
<?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 PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
use stdClass;
#[CoversClass(ObjectExporterChain::class)]
#[UsesClass(Exporter::class)]
#[Small]
final class ObjectExporterChainTest extends TestCase
{
public function testHandlesObjectWhenAtLeastOneComposedObjectExporterHandlesIt(): void
{
$chain = new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
new ObjectExporterThatHandlesEveryObject,
],
);
$this->assertTrue($chain->handles(new stdClass));
}
public function testDoesNotHandleObjectWhenNoComposedObjectExporterHandlesIt(): void
{
$chain = new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
],
);
$this->assertFalse($chain->handles(new stdClass));
}
public function testDelegatesExportingToFirstComposedObjectExporterThatHandlesObject(): void
{
$chain = new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
new ObjectExporterThatHandlesObjectsOfSpecificType(stdClass::class),
new ObjectExporterThatHandlesEveryObject,
],
);
$this->assertSame(
'stdClass handled by custom exporter',
$chain->export(new stdClass, new Exporter, 0),
);
$this->assertSame(
ExampleClass::class . ' (indentation: 0)',
$chain->export(new ExampleClass('foo'), new Exporter, 0),
);
}
public function testCannotExportObjectWhenNoComposedObjectExporterHandlesIt(): void
{
$chain = new ObjectExporterChain(
[
new ObjectExporterThatHandlesNoObject,
],
);
$this->expectException(ObjectNotSupportedException::class);
$chain->export(new stdClass, new Exporter, 0);
}
}

View File

@ -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;
use function sprintf;
final readonly class ObjectExporterThatHandlesEveryObject implements ObjectExporter
{
public function handles(object $object): bool
{
return true;
}
public function export(object $object, Exporter $exporter, int $indentation): string
{
return sprintf(
'%s (indentation: %d)',
$object::class,
$indentation,
);
}
}

View File

@ -0,0 +1,23 @@
<?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 ObjectExporterThatHandlesNoObject implements ObjectExporter
{
public function handles(object $object): bool
{
return false;
}
public function export(object $object, Exporter $exporter, int $indentation): string
{
throw new ObjectNotSupportedException;
}
}

View File

@ -0,0 +1,41 @@
<?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 sprintf;
final readonly class ObjectExporterThatHandlesObjectsOfSpecificType implements ObjectExporter
{
/**
* @var class-string
*/
private string $type;
/**
* @param class-string $type
*/
public function __construct(string $type)
{
$this->type = $type;
}
public function handles(object $object): bool
{
return $object instanceof $this->type;
}
public function export(object $object, Exporter $exporter, int $indentation): string
{
return sprintf(
'%s handled by custom exporter',
$object::class,
);
}
}