From 3f33ba4c670a2555e0ffa53bf075539c8b41966d Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Mon, 13 Jul 2026 13:48:57 +0200 Subject: [PATCH] Introduce ObjectExporter interface and ObjectExporterChain for customizing how objects are exported --- .github/workflows/ci.yaml | 2 +- ChangeLog.md | 7 ++ composer.json | 5 +- src/Exporter.php | 8 +- src/ObjectExporter.php | 20 ++++ src/ObjectExporterChain.php | 50 ++++++++ src/exceptions/Exception.php | 16 +++ .../ObjectNotSupportedException.php | 16 +++ tests/ExporterTest.php | 110 ++++++++++++++++++ tests/ObjectExporterChainTest.php | 79 +++++++++++++ .../ObjectExporterThatHandlesEveryObject.php | 29 +++++ .../ObjectExporterThatHandlesNoObject.php | 23 ++++ ...porterThatHandlesObjectsOfSpecificType.php | 41 +++++++ 13 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 src/ObjectExporter.php create mode 100644 src/ObjectExporterChain.php create mode 100644 src/exceptions/Exception.php create mode 100644 src/exceptions/ObjectNotSupportedException.php create mode 100644 tests/ObjectExporterChainTest.php create mode 100644 tests/_fixture/ObjectExporterThatHandlesEveryObject.php create mode 100644 tests/_fixture/ObjectExporterThatHandlesNoObject.php create mode 100644 tests/_fixture/ObjectExporterThatHandlesObjectsOfSpecificType.php diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9a3dc84..6bb7266 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ concurrency: cancel-in-progress: true env: - COMPOSER_ROOT_VERSION: 8.1.x-dev + COMPOSER_ROOT_VERSION: 8.2.x-dev PHP_VERSION: 8.4 permissions: diff --git a/ChangeLog.md b/ChangeLog.md index e7313aa..8592f26 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,12 @@ 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 ### 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 +[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.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 diff --git a/composer.json b/composer.json index 318c0b2..5e2c889 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "optimize-autoloader": true, "sort-packages": true }, + "minimum-stability": "dev", "prefer-stable": true, "require": { "php": ">=8.4", @@ -44,7 +45,7 @@ "sebastian/recursion-context": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^13.2.4" + "phpunit/phpunit": "^13.3" }, "autoload": { "classmap": [ @@ -58,7 +59,7 @@ }, "extra": { "branch-alias": { - "dev-main": "8.1-dev" + "dev-main": "8.2-dev" } } } diff --git a/src/Exporter.php b/src/Exporter.php index 5fc8275..579c571 100644 --- a/src/Exporter.php +++ b/src/Exporter.php @@ -61,15 +61,17 @@ final readonly class Exporter * @var positive-int */ private int $maxLengthForStrings; + private ?ObjectExporterChain $objectExporter; /** * @param non-negative-int $shortenArraysLongerThan * @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->maxLengthForStrings = $maxLengthForStrings; + $this->objectExporter = $objectExporter; } /** @@ -524,6 +526,10 @@ final readonly class Exporter $processed->add($value); + if ($this->objectExporter !== null && $this->objectExporter->handles($value)) { + return $this->objectExporter->export($value, $this, $indentation); + } + $array = $this->toArray($value); $buffer = ''; diff --git a/src/ObjectExporter.php b/src/ObjectExporter.php new file mode 100644 index 0000000..30c5f42 --- /dev/null +++ b/src/ObjectExporter.php @@ -0,0 +1,20 @@ + + * + * 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; +} diff --git a/src/ObjectExporterChain.php b/src/ObjectExporterChain.php new file mode 100644 index 0000000..ebe474a --- /dev/null +++ b/src/ObjectExporterChain.php @@ -0,0 +1,50 @@ + + * + * 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 + */ + private array $exporters; + + /** + * @param non-empty-list $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; + } +} diff --git a/src/exceptions/Exception.php b/src/exceptions/Exception.php new file mode 100644 index 0000000..279613b --- /dev/null +++ b/src/exceptions/Exception.php @@ -0,0 +1,16 @@ + + * + * 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 +{ +} diff --git a/src/exceptions/ObjectNotSupportedException.php b/src/exceptions/ObjectNotSupportedException.php new file mode 100644 index 0000000..4e0bf96 --- /dev/null +++ b/src/exceptions/ObjectNotSupportedException.php @@ -0,0 +1,16 @@ + + * + * 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 +{ +} diff --git a/tests/ExporterTest.php b/tests/ExporterTest.php index 768633c..2fee85c 100644 --- a/tests/ExporterTest.php +++ b/tests/ExporterTest.php @@ -30,6 +30,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use ReflectionClass; use SebastianBergmann\RecursionContext\Context; @@ -37,6 +38,7 @@ use SplObjectStorage; use stdClass; #[CoversClass(Exporter::class)] +#[UsesClass(ObjectExporterChain::class)] #[Small] final class ExporterTest extends TestCase { @@ -649,6 +651,114 @@ EOF; $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 { return (string) preg_replace('/[ ]*\n/', "\n", $string); diff --git a/tests/ObjectExporterChainTest.php b/tests/ObjectExporterChainTest.php new file mode 100644 index 0000000..1ab356c --- /dev/null +++ b/tests/ObjectExporterChainTest.php @@ -0,0 +1,79 @@ + + * + * 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); + } +} diff --git a/tests/_fixture/ObjectExporterThatHandlesEveryObject.php b/tests/_fixture/ObjectExporterThatHandlesEveryObject.php new file mode 100644 index 0000000..ede777b --- /dev/null +++ b/tests/_fixture/ObjectExporterThatHandlesEveryObject.php @@ -0,0 +1,29 @@ + + * + * 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, + ); + } +} diff --git a/tests/_fixture/ObjectExporterThatHandlesNoObject.php b/tests/_fixture/ObjectExporterThatHandlesNoObject.php new file mode 100644 index 0000000..61010ef --- /dev/null +++ b/tests/_fixture/ObjectExporterThatHandlesNoObject.php @@ -0,0 +1,23 @@ + + * + * 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; + } +} diff --git a/tests/_fixture/ObjectExporterThatHandlesObjectsOfSpecificType.php b/tests/_fixture/ObjectExporterThatHandlesObjectsOfSpecificType.php new file mode 100644 index 0000000..887caac --- /dev/null +++ b/tests/_fixture/ObjectExporterThatHandlesObjectsOfSpecificType.php @@ -0,0 +1,41 @@ + + * + * 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, + ); + } +}