Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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', [
Expand Down
39 changes: 30 additions & 9 deletions src/Context/ObjectProcessor/FallbackProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>
* @api
*/
final class FallbackProcessor implements ObjectProcessor
{
/** @var \SplObjectStorage<object, null> Objects whose export is in progress. */
private readonly \SplObjectStorage $exporting;

public function __construct()
{
$this->exporting = new \SplObjectStorage();
}

#[\Override]
public function canProcess(object $value): bool
{
Expand All @@ -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;
}
}
75 changes: 69 additions & 6 deletions tests/Unit/Context/ObjectProcessor/FallbackProcessorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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();
Expand All @@ -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
{
Expand Down
Loading