diff --git a/ChangeLog.md b/ChangeLog.md index ee83f69..6eb49eb 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -4,6 +4,10 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](htt ## [6.1.0] - 2024-MM-DD +### Added + +* [#56](https://github.com/sebastianbergmann/exporter/pull/56): The export of objects can now be customized using a chain of (see `ObjectExporterChain`) `ObjectExporter` objects + ## [6.0.1] - 2024-03-02 ### Changed diff --git a/src/Exporter.php b/src/Exporter.php index 29244ae..48aa52d 100644 --- a/src/Exporter.php +++ b/src/Exporter.php @@ -37,6 +37,13 @@ final readonly class Exporter { + private ?ObjectExporterChain $objectExporter; + + public function __construct(?ObjectExporterChain $objectExporter = null) + { + $this->objectExporter = $objectExporter; + } + /** * Exports a value as a string. * @@ -329,7 +336,7 @@ private function exportArray(array &$value, RecursionContext $processed, int $in return 'Array &' . (string) $key . ' [' . $values . ']'; } - private function exportObject(mixed $value, RecursionContext $processed, int $indentation): string + private function exportObject(object $value, RecursionContext $processed, int $indentation): string { $class = $value::class; @@ -339,13 +346,24 @@ private function exportObject(mixed $value, RecursionContext $processed, int $in $processed->add($value); - $array = $this->toArray($value); - $values = ''; + if ($this->objectExporter !== null && $this->objectExporter->handles($value)) { + $buffer = $this->objectExporter->export($value, $this, $indentation); + } else { + $buffer = $this->defaultObjectExport($value, $processed, $indentation); + } + + return $class . ' Object #' . spl_object_id($value) . ' (' . $buffer . ')'; + } + + private function defaultObjectExport(object $object, RecursionContext $processed, int $indentation): string + { + $array = $this->toArray($object); + $buffer = ''; $whitespace = str_repeat(' ', 4 * $indentation); if (count($array) > 0) { foreach ($array as $k => $v) { - $values .= + $buffer .= $whitespace . ' ' . $this->recursiveExport($k, $indentation) @@ -354,9 +372,9 @@ private function exportObject(mixed $value, RecursionContext $processed, int $in . ",\n"; } - $values = "\n" . $values . $whitespace; + $buffer = "\n" . $buffer . $whitespace; } - return $class . ' Object #' . spl_object_id($value) . ' (' . $values . ')'; + return $buffer; } } diff --git a/src/ObjectExporter.php b/src/ObjectExporter.php new file mode 100644 index 0000000..535aa53 --- /dev/null +++ b/src/ObjectExporter.php @@ -0,0 +1,17 @@ + + * + * 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; + + 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..2efd7f3 --- /dev/null +++ b/src/ObjectExporterChain.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\Exporter; + +final class ObjectExporterChain implements ObjectExporter +{ + /** + * @psalm-var non-empty-list + */ + private array $exporter; + + /** + * @psalm-param non-empty-list $exporter + */ + public function __construct(array $exporter) + { + $this->exporter = $exporter; + } + + public function handles(object $object): bool + { + foreach ($this->exporter as $exporter) { + if ($exporter->handles($object)) { + return true; + } + } + + return false; + } + + /** + * @throws ObjectNotSupportedException + */ + public function export(object $object, Exporter $exporter, int $indentation): string + { + foreach ($this->exporter as $objectExporter) { + if ($objectExporter->handles($object)) { + return $objectExporter->export($object, $exporter, $indentation); + } + } + + throw new ObjectNotSupportedException; + } +} diff --git a/src/exception/Exception.php b/src/exception/Exception.php new file mode 100644 index 0000000..279613b --- /dev/null +++ b/src/exception/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/exception/ObjectNotSupportedException.php b/src/exception/ObjectNotSupportedException.php new file mode 100644 index 0000000..4e0bf96 --- /dev/null +++ b/src/exception/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 b4fcb55..afd1a3e 100644 --- a/tests/ExporterTest.php +++ b/tests/ExporterTest.php @@ -26,12 +26,14 @@ 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 SebastianBergmann\RecursionContext\Context; use SplObjectStorage; use stdClass; #[CoversClass(Exporter::class)] +#[UsesClass(ObjectExporterChain::class)] #[Small] final class ExporterTest extends TestCase { @@ -447,6 +449,27 @@ public function testShortenedRecursiveOccurredRecursion(): void $this->assertEquals('*RECURSION*', (new Exporter)->shortenedRecursiveExport($value, $context)); } + public function testExportOfObjectsCanBeCustomized(): void + { + $objectExporter = $this->createStub(ObjectExporter::class); + $objectExporter->method('handles')->willReturn(true); + $objectExporter->method('export')->willReturn('custom object export'); + + $exporter = new Exporter(new ObjectExporterChain([$objectExporter])); + + $this->assertStringMatchesFormat( + <<<'EOT' +Array &0 [ + 0 => stdClass Object #%d (custom object export), + 1 => stdClass Object #%d (custom object export), +] +EOT + , + $exporter->export([new stdClass, new stdClass]), + ); + + } + private function trimNewline(string $string): string { return preg_replace('/[ ]*\n/', "\n", $string); diff --git a/tests/ObjectExporterChainTest.php b/tests/ObjectExporterChainTest.php new file mode 100644 index 0000000..bb901fc --- /dev/null +++ b/tests/ObjectExporterChainTest.php @@ -0,0 +1,64 @@ + + * + * 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 testCanBeQueriedWhetherChainedExporterHandlesAnObject(): void + { + $firstExporter = $this->createStub(ObjectExporter::class); + $firstExporter->method('handles')->willReturn(false); + + $secondExporter = $this->createStub(ObjectExporter::class); + $secondExporter->method('handles')->willReturn(true); + + $chain = new ObjectExporterChain([$firstExporter]); + $this->assertFalse($chain->handles(new stdClass)); + + $chain = new ObjectExporterChain([$firstExporter, $secondExporter]); + $this->assertTrue($chain->handles(new stdClass)); + } + + public function testDelegatesExportingToFirstExporterThatHandlesAnObject(): void + { + $firstExporter = $this->createStub(ObjectExporter::class); + $firstExporter->method('handles')->willReturn(false); + $firstExporter->method('export')->willThrowException(new ObjectNotSupportedException); + + $secondExporter = $this->createStub(ObjectExporter::class); + $secondExporter->method('handles')->willReturn(true); + $secondExporter->method('export')->willReturn('string'); + + $chain = new ObjectExporterChain([$firstExporter, $secondExporter]); + + $this->assertSame('string', $chain->export(new stdClass, new Exporter, 0)); + } + + public function testCannotExportObjectWhenNoExporterHandlesIt(): void + { + $firstExporter = $this->createStub(ObjectExporter::class); + $firstExporter->method('handles')->willReturn(false); + + $chain = new ObjectExporterChain([$firstExporter]); + + $this->expectException(ObjectNotSupportedException::class); + + $this->assertSame('string', $chain->export(new stdClass, new Exporter, 0)); + } +}