diff --git a/README.md b/README.md index 2e3d2ba..c23465b 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ By default, `RpcLogger` uses `DefaultProcessor` which can handle: - **DateTimeProcessor**: Converts `\DateTimeInterface` objects to ISO 8601 format (ATOM) - **ThrowableProcessor**: Converts exceptions/errors to structured arrays with class, message, code, file, line, and trace - **StringableProcessor**: Converts `\Stringable` objects to their string representation - - **FallbackProcessor**: Converts any other objects to arrays with class name and public properties + - **FallbackProcessor**: Converts any other objects to arrays with class name and public properties. Reference cycles are cut: a property pointing back to the object itself or to an object that contains it is left out, and an object reached again through an array is exported as its class name only ```php $logger->info('Order processed', [ diff --git a/src/Context/ObjectProcessor/FallbackProcessor.php b/src/Context/ObjectProcessor/FallbackProcessor.php index d093338..7adb30c 100644 --- a/src/Context/ObjectProcessor/FallbackProcessor.php +++ b/src/Context/ObjectProcessor/FallbackProcessor.php @@ -9,11 +9,23 @@ /** * Fallback processor for unknown objects. * + * Exports the class name and public properties. A property that references an object still being + * exported (the object itself or one of its owners) is left out, and an object reached again through + * an array or another processor is exported as its class name only, so reference cycles terminate. + * * @implements ObjectProcessor * @api */ final class FallbackProcessor implements ObjectProcessor { + /** @var \SplObjectStorage Objects whose export is in progress. */ + private readonly \SplObjectStorage $exporting; + + public function __construct() + { + $this->exporting = new \SplObjectStorage(); + } + #[\Override] public function canProcess(object $value): bool { @@ -23,17 +35,26 @@ public function canProcess(object $value): bool #[\Override] public function process(object $value, callable $processor): array { - $result = ['@class' => $value::class] + \get_object_vars($value); - /** @var mixed $v */ - foreach ($result as $k => &$v) { - if ($v === $value) { - unset($result[$k]); - } + if ($this->exporting->contains($value)) { + return ['@class' => $value::class]; + } - /** @var mixed $v */ - $v = $processor($v); + $properties = []; + $this->exporting->attach($value); + try { + /** @var mixed $property */ + foreach (\get_object_vars($value) as $name => $property) { + if (\is_object($property) && $this->exporting->contains($property)) { + continue; + } + + /** @var mixed */ + $properties[$name] = $processor($property); + } + } finally { + $this->exporting->detach($value); } - return $result; + return ['@class' => $value::class] + $properties; } } diff --git a/tests/Unit/Context/ObjectProcessor/FallbackProcessorTest.php b/tests/Unit/Context/ObjectProcessor/FallbackProcessorTest.php index 13357a5..6e6a9f8 100644 --- a/tests/Unit/Context/ObjectProcessor/FallbackProcessorTest.php +++ b/tests/Unit/Context/ObjectProcessor/FallbackProcessorTest.php @@ -9,7 +9,7 @@ use Testo\Test; use Testo\Assert; use Testo\Lifecycle\BeforeTest; -use Testo\Skip; +use RoadRunner\PsrLogger\Context\DefaultProcessor; use RoadRunner\PsrLogger\Context\ObjectProcessor\FallbackProcessor; #[Covers(FallbackProcessor::class)] @@ -80,16 +80,13 @@ public function dropsPropertyReferencingTheObjectItself(): void $value = new \stdClass(); $value->name = 'node'; $value->self = $value; + $value->after = 'kept'; $result = $this->processor->process($value, static fn(mixed $v): mixed => $v); - Assert::same($result, ['@class' => 'stdClass', 'name' => 'node']); + Assert::same($result, ['@class' => 'stdClass', 'name' => 'node', 'after' => 'kept']); } - /** - * With the default processor as the callback this recursion never ends and exhausts memory. - */ - #[Skip('FallbackProcessor::process() has no `continue` after dropping a self-reference, so the object is still passed to the processor')] public function doesNotPassSelfReferenceToProcessor(): void { $value = new \stdClass(); @@ -104,6 +101,72 @@ public function doesNotPassSelfReferenceToProcessor(): void Assert::false(\in_array($value, $seen, true)); } + public function selfReferenceTerminatesWithDefaultProcessor(): void + { + $value = new \stdClass(); + $value->name = 'node'; + $value->self = $value; + + $result = DefaultProcessor::createDefault()($value); + + Assert::same($result, ['@class' => 'stdClass', 'name' => 'node']); + } + + public function dropsPropertyReferencingAnOwner(): void + { + $parent = new \stdClass(); + $child = new \stdClass(); + $parent->name = 'parent'; + $parent->child = $child; + $child->name = 'child'; + $child->parent = $parent; + + $result = DefaultProcessor::createDefault()($parent); + + Assert::same($result, [ + '@class' => 'stdClass', + 'name' => 'parent', + 'child' => ['@class' => 'stdClass', 'name' => 'child'], + ]); + } + + public function exportsObjectReachedAgainThroughArrayAsClassOnly(): void + { + $value = new \stdClass(); + $value->items = [$value, 'item']; + + $result = DefaultProcessor::createDefault()($value); + + Assert::same($result, ['@class' => 'stdClass', 'items' => [['@class' => 'stdClass'], 'item']]); + } + + public function exportsSharedObjectInEveryPlace(): void + { + $shared = (object) ['id' => 1]; + $value = (object) ['first' => $shared, 'second' => $shared]; + + $result = DefaultProcessor::createDefault()($value); + + Assert::same($result, [ + '@class' => 'stdClass', + 'first' => ['@class' => 'stdClass', 'id' => 1], + 'second' => ['@class' => 'stdClass', 'id' => 1], + ]); + } + + public function exportsObjectFullyAgainAfterProcessorFailed(): void + { + $value = (object) ['id' => 1]; + try { + $this->processor->process($value, static fn(mixed $v): never => throw new \RuntimeException('processor failed')); + } catch (\RuntimeException) { + } + + $result = $this->processor->process($value, static fn(mixed $v): mixed => $v); + + Assert::same($result, ['@class' => 'stdClass', 'id' => 1]); + } + #[BeforeTest] protected function setUp(): void {