Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make export of objects customizable #56

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 32 additions & 14 deletions src/Exporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@

final readonly class Exporter
sebastianbergmann marked this conversation as resolved.
Show resolved Hide resolved
{
private ?ObjectExporterChain $objectExporter;

public function __construct(?ObjectExporterChain $objectExporter = null)
{
$this->objectExporter = $objectExporter;
}

/**
* Exports a value as a string.
*
Expand Down Expand Up @@ -247,18 +254,16 @@ private function recursiveExport(mixed &$value, int $indentation = 0, ?Recursion
return $this->exportString($value);
}

$whitespace = str_repeat(' ', 4 * $indentation);

if (!$processed) {
$processed = new RecursionContext;
}

if (is_array($value)) {
return $this->exportArray($value, $processed, $whitespace, $indentation);
return $this->exportArray($value, $processed, $indentation);
}

if (is_object($value)) {
return $this->exportObject($value, $processed, $whitespace, $indentation);
return $this->exportObject($value, $processed, $indentation);
}

return var_export($value, true);
Expand Down Expand Up @@ -303,15 +308,16 @@ private function exportString(string $value): string
"'";
}

private function exportArray(array &$value, RecursionContext $processed, string $whitespace, int $indentation): string
private function exportArray(array &$value, RecursionContext $processed, int $indentation): string
{
if (($key = $processed->contains($value)) !== false) {
return 'Array &' . $key;
}

$array = $value;
$key = $processed->add($value);
$values = '';
$array = $value;
$key = $processed->add($value);
$values = '';
$whitespace = str_repeat(' ', 4 * $indentation);

if (count($array) > 0) {
foreach ($array as $k => $v) {
Expand All @@ -330,7 +336,7 @@ private function exportArray(array &$value, RecursionContext $processed, string
return 'Array &' . (string) $key . ' [' . $values . ']';
}

private function exportObject(mixed $value, RecursionContext $processed, string $whitespace, int $indentation): string
private function exportObject(object $value, RecursionContext $processed, int $indentation): string
{
$class = $value::class;

Expand All @@ -340,12 +346,24 @@ private function exportObject(mixed $value, RecursionContext $processed, string

$processed->add($value);

$values = '';
$array = $this->toArray($value);
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 . ')';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one more thing I just noticed.

the $class . ' Object #' prefix will only be present in "root objects". in custom object exporters this $class . ' Object #' format needs to be replicated, even if the custom exporter delegates export back to the built-in one.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we deal with that in Exporter::exportObject() or should we trust implementors of custom object exporters to do the right thing?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a way how Exporter::exportObject() could do that in a way which would even work when custom exporters would delegate exporting of some stuff back to the exporter object.

Maybe we just need a test-case so we can see whether it works already or what a custom ObjectExporter needs todo to get it right

Copy link
Contributor

@staabm staabm Apr 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the problem would be less theoretic if we try to implement a real world exporter

Maybe @BladeMF could try his use-case..?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that ... and would rather not make a release without this feature having been validated through real world use cases.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BladeMF ping

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BladeMF Can you help us?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, missed it in April. Let me have a look.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, we can try some pseudo-code first and see where that takes us, OK?

So, if I was to write a Doctrine entity exporter the case might be something like the following. A typical entity object graph might look like this (the notation is (<class-name>)#<instance-name> for classes and then <property-type> $<property-name>: <property-value>, the below is taken from a recent object but simplified):

(Folder)#1 {
  int $id: 4,
  string $name: "Folder 1",
  array|(ArrayAccess&Countable&Traversable) $visibleToEmployees: (PersistentCollection)#2 {
    (listing the items below)
    (Employee)#3 { ... }
    (Employee)#4 { ... }
    (Employee)#5 { ... }
  }
  array|(ArrayAccess&Countable&Traversable) $editableByEmployees: (PersistentCollection)#6 {
    _listing the items below_
    (Employee)#3 { ... } (this instance appears a second time)
    (Employee)#6 { ... }
    (Employee)#7 { ... }
  }
}

so the exporter code would be something like this:

public function export(mixed $object, Exporter $defaultExporter, int $indentLevel): string
{
  // we assume we already know it's an entity
  $metadata = $this->metadataFactory->getMetadata($object);
  $indent = str_repeat("  ", $indent);
  $indentPlus1 = str_repeat("  ", $indent + 1);
  $lines = [];
  foreach($metadata->properties as $property) {
    $value = $property->getValue($object);
    if($value instanceof Collection) {
      // it's a Doctrine collection
      $objectLines = [];
      foreach($value as $item){
        // see coment below about calling the default exporter
        $objectLines [] = $indentPlus1. $defaultExporter->export($value, $indent + 1); 
      }
      $lines[] = $indent . $defaultExporter->formatExport($value, implode("\n". $indent, $objectLines));
      continue;
    }

    // Use the defaut export, it will call me back to check if I support it if is an entity
    // I could check it here and do a shortcut, but this is way more cleaner:
    // if it's an entity, I get the list of properties from the repository (as opposed to all class properties)
    // and then handle the collections.
    // There also could be another exporter with greater priority than me, 
    // so it's not really legal for me to jump the line
    $lines[] = $defaultExporter->export($value, $indent + 1); 
  }
  return "\n".implode("\n". $indent, $lines).
}

the formatExport function might look something as silly as:

function formatExport(object $object, string $buffer): string
{
  return $object::class . ' Object #' . spl_object_id($object) . ' (' . $buffer . ')';
}

It appears that a formatExport function is needed. I think the purpose of the custom exporter is just to export a specific class, nothing more, nothing less. It should defer anything else to the default exporter. I am not even sure the formatExport function is in the Exporter, it looks like a separate formatter class may be needed, but I won't die on that hill. I also kind of dislike the need for me to deal with the indent before the closing brace ), because it's mine-1, but I don't really know that, because the default formatter/exporter determines that, if that makes sense.

If the above (a bit messy) code is not convincing I could try and write one for real.

I am in the middle of moving to the seaside for 3 months, but I will try and think more about that code. It may very well be the case that I need to write one real.

}

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)
Expand All @@ -354,9 +372,9 @@ private function exportObject(mixed $value, RecursionContext $processed, string
. ",\n";
}

$values = "\n" . $values . $whitespace;
$buffer = "\n" . $buffer . $whitespace;
}

return $class . ' Object #' . spl_object_id($value) . ' (' . $values . ')';
return $buffer;
}
}
17 changes: 17 additions & 0 deletions src/ObjectExporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?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
sebastianbergmann marked this conversation as resolved.
Show resolved Hide resolved
{
public function handles(object $object): bool;

public function export(object $object, Exporter $exporter, int $indentation): string;
}
51 changes: 51 additions & 0 deletions src/ObjectExporterChain.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?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 class ObjectExporterChain implements ObjectExporter
{
/**
* @psalm-var non-empty-list<ObjectExporter>
*/
private array $exporter;

/**
* @psalm-param non-empty-list<ObjectExporter> $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;
}
}
16 changes: 16 additions & 0 deletions src/exception/Exception.php
Original file line number Diff line number Diff line change
@@ -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
{
}
16 changes: 16 additions & 0 deletions src/exception/ObjectNotSupportedException.php
Original file line number Diff line number Diff line change
@@ -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
{
}
23 changes: 23 additions & 0 deletions tests/ExporterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
Expand Down
64 changes: 64 additions & 0 deletions tests/ObjectExporterChainTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?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 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));
}
}