From 11bdcd19bcd902f2290e0e709aada5e4415f4566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Gren=C4=8D=C3=ADk?= Date: Fri, 21 Aug 2026 07:23:24 +0200 Subject: [PATCH 1/4] fix(mapper): serialize nested objects --- .../src/Mappers/ObjectToArrayMapper.php | 10 ++++++- .../Mapper/Fixtures/NestedObjectWithDate.php | 17 +++++++++++ .../ObjectWithNestedObjectAndDate.php | 20 +++++++++++++ .../Mappers/ObjectToArrayMapperTest.php | 28 +++++++++++++++++++ .../Mapper/Mappers/ObjectToJsonMapperTest.php | 28 +++++++++++++++++++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Mapper/Fixtures/NestedObjectWithDate.php create mode 100644 tests/Integration/Mapper/Fixtures/ObjectWithNestedObjectAndDate.php diff --git a/packages/mapper/src/Mappers/ObjectToArrayMapper.php b/packages/mapper/src/Mappers/ObjectToArrayMapper.php index 675d1c012..0c491870c 100644 --- a/packages/mapper/src/Mappers/ObjectToArrayMapper.php +++ b/packages/mapper/src/Mappers/ObjectToArrayMapper.php @@ -69,7 +69,9 @@ private function resolvePropertyValue(PropertyReflector $property, object $objec continue; } - $propertyValue[$key] = map($value)->toArray(); + $propertyValue[$key] = map($value) + ->in($this->context) + ->toArray(); } return $propertyValue; @@ -79,6 +81,12 @@ private function resolvePropertyValue(PropertyReflector $property, object $objec return $serializer->serialize($propertyValue); } + if ($propertyValue !== null && is_object($propertyValue)) { + return map($propertyValue) + ->in($this->context) + ->toArray(); + } + return $propertyValue; } diff --git a/tests/Integration/Mapper/Fixtures/NestedObjectWithDate.php b/tests/Integration/Mapper/Fixtures/NestedObjectWithDate.php new file mode 100644 index 000000000..bd1b53536 --- /dev/null +++ b/tests/Integration/Mapper/Fixtures/NestedObjectWithDate.php @@ -0,0 +1,17 @@ +toArray(); + + $this->assertSame( + [ + 'createdAt' => '2026-08-19T12:34:56.000Z', + 'child' => [ + 'createdAt' => '2026-08-19T12:34:56.000Z', + ], + 'children' => [ + ['createdAt' => '2026-08-19T12:34:56.000Z'], + ], + ], + $array, + ); + } } diff --git a/tests/Integration/Mapper/Mappers/ObjectToJsonMapperTest.php b/tests/Integration/Mapper/Mappers/ObjectToJsonMapperTest.php index a570c3ca7..1ec15b765 100644 --- a/tests/Integration/Mapper/Mappers/ObjectToJsonMapperTest.php +++ b/tests/Integration/Mapper/Mappers/ObjectToJsonMapperTest.php @@ -5,8 +5,11 @@ namespace Tests\Tempest\Integration\Mapper\Mappers; use PHPUnit\Framework\Attributes\Test; +use Tempest\DateTime\DateTime; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; +use Tests\Tempest\Integration\Mapper\Fixtures\NestedObjectWithDate; use Tests\Tempest\Integration\Mapper\Fixtures\ObjectA; +use Tests\Tempest\Integration\Mapper\Fixtures\ObjectWithNestedObjectAndDate; use Tests\Tempest\Integration\Mapper\Fixtures\ObjectWithScalarValues; use function Tempest\Mapper\map; @@ -42,4 +45,29 @@ public function object_with_scalar_values_to_json(): void json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR), ); } + + #[Test] + public function object_with_single_nested_object_to_json(): void + { + $date = DateTime::parse('2026-08-19T12:34:56+00:00'); + + $json = map(new ObjectWithNestedObjectAndDate( + createdAt: $date, + child: new NestedObjectWithDate($date), + children: [new NestedObjectWithDate($date)], + ))->toJson(); + + $this->assertSame( + [ + 'createdAt' => '2026-08-19T12:34:56.000Z', + 'child' => [ + 'createdAt' => '2026-08-19T12:34:56.000Z', + ], + 'children' => [ + ['createdAt' => '2026-08-19T12:34:56.000Z'], + ], + ], + json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR), + ); + } } From 2ef766cd865fe56d837a5cd44b9957cb9f0452e6 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 21 Aug 2026 20:46:10 +0200 Subject: [PATCH 2/4] test: add failing tests --- .../Mappers/ObjectToArrayMapperTest.php | 87 +++++++++++++++++++ .../ArrayOfObjectsSerializerTest.php | 55 ++++++++++++ 2 files changed, 142 insertions(+) diff --git a/tests/Integration/Mapper/Mappers/ObjectToArrayMapperTest.php b/tests/Integration/Mapper/Mappers/ObjectToArrayMapperTest.php index 937e7daab..4e3ef2887 100644 --- a/tests/Integration/Mapper/Mappers/ObjectToArrayMapperTest.php +++ b/tests/Integration/Mapper/Mappers/ObjectToArrayMapperTest.php @@ -4,8 +4,13 @@ namespace Tests\Tempest\Integration\Mapper\Mappers; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\Attributes\Test; +use RuntimeException; use Tempest\DateTime\DateTime; +use Tempest\Mapper\Mapper; +use Tempest\Mapper\MapperConfig; +use Tempest\Support\Json\Exception\JsonCouldNotBeEncoded; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\Mapper\Fixtures\NestedObjectWithDate; use Tests\Tempest\Integration\Mapper\Fixtures\ObjectA; @@ -13,6 +18,7 @@ use Tests\Tempest\Integration\Mapper\Fixtures\ObjectWithNestedObjectAndDate; use Tests\Tempest\Integration\Mapper\Fixtures\ObjectWithNullableProperties; use Tests\Tempest\Integration\Mapper\Fixtures\ObjectWithScalarValues; +use Tests\Tempest\Integration\Mapper\Fixtures\ParentObject; use function Tempest\Mapper\map; @@ -97,4 +103,85 @@ public function object_with_single_nested_object_to_array(): void $array, ); } + + #[Test] + #[RequiresPhpExtension('pcntl')] + public function cyclic_nested_objects_fail_instead_of_hanging(): void + { + $parent = map([ + 'name' => 'parent', + 'child' => ['name' => 'child'], + ])->to(ParentObject::class); + $this->assertInstanceOf(ParentObject::class, $parent); + + pcntl_async_signals(true); + pcntl_signal(SIGALRM, static function (): never { + throw new RuntimeException('Serialization did not terminate'); + }); + pcntl_alarm(2); + + try { + $this->expectException(JsonCouldNotBeEncoded::class); + + map($parent)->toJson(); + } finally { + pcntl_alarm(0); + pcntl_signal(SIGALRM, SIG_DFL); + } + } + + #[Test] + public function nested_objects_do_not_resolve_unused_mappers(): void + { + map(new ObjectA('a', 'b'))->toArray(); + + $this->container + ->get(MapperConfig::class) + ->addMapper(MapperResolutionProbe::class); + + MapperResolutionProbe::$constructions = 0; + + map(new ObjectWithMapperResolutionChildren([ + new MapperResolutionChild('a'), + new MapperResolutionChild('b'), + new MapperResolutionChild('c'), + ]))->toArray(); + + $this->assertSame(0, MapperResolutionProbe::$constructions); + } +} + +final readonly class ObjectWithMapperResolutionChildren +{ + public function __construct( + /** @var \Tests\Tempest\Integration\Mapper\Mappers\MapperResolutionChild[] */ + public array $children, + ) {} +} + +final readonly class MapperResolutionChild +{ + public function __construct( + public string $name, + ) {} +} + +final class MapperResolutionProbe implements Mapper +{ + public static int $constructions = 0; + + public function __construct() + { + self::$constructions++; + } + + public function canMap(mixed $from, mixed $to): bool + { + return false; + } + + public function map(mixed $from, mixed $to): mixed + { + return $from; + } } diff --git a/tests/Integration/Mapper/Serializers/ArrayOfObjectsSerializerTest.php b/tests/Integration/Mapper/Serializers/ArrayOfObjectsSerializerTest.php index 87d493347..a721baf04 100644 --- a/tests/Integration/Mapper/Serializers/ArrayOfObjectsSerializerTest.php +++ b/tests/Integration/Mapper/Serializers/ArrayOfObjectsSerializerTest.php @@ -3,9 +3,16 @@ namespace Tests\Tempest\Integration\Mapper\Serializers; use PHPUnit\Framework\Attributes\Test; +use Tempest\Mapper\DynamicSerializer; use Tempest\Mapper\Exceptions\ValueCouldNotBeSerialized; +use Tempest\Mapper\MappingContext; +use Tempest\Mapper\Serializer; +use Tempest\Mapper\SerializerFactory; use Tempest\Mapper\Serializers\ArrayOfObjectsSerializer; use Tempest\Mapper\Serializers\EnumSerializer; +use Tempest\Reflection\PropertyReflector; +use Tempest\Reflection\TypeReflector; +use Tempest\Support\Priority; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; use Tests\Tempest\Integration\Mapper\Fixtures\ObjectWithSerializerProperties; @@ -51,4 +58,52 @@ public function only_arrays_allowed(): void new EnumSerializer()->serialize('foo'); } + + #[Test] + public function uses_mapping_context_for_nested_objects(): void + { + $context = MappingContext::from('api'); + + $this->container + ->get(SerializerFactory::class) + ->addSerializer( + ContextStringSerializer::class, + priority: Priority::HIGHEST, + context: $context, + ); + + $serializer = $this->container->get( + ArrayOfObjectsSerializer::class, + context: $context, + ); + + $this->assertSame( + [['name' => 'api:a']], + $serializer->serialize([new ContextObject('a')]), + ); + } +} + +final readonly class ContextObject +{ + public function __construct( + public string $name, + ) {} +} + +final class ContextStringSerializer implements Serializer, DynamicSerializer +{ + public static function accepts(PropertyReflector|TypeReflector $input): bool + { + $type = $input instanceof PropertyReflector + ? $input->getType() + : $input; + + return $type->getName() === 'string'; + } + + public function serialize(mixed $input): string + { + return 'api:' . $input; + } } From e3dccac63c874b43d4ca15dc059ae7b3c034e277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Gren=C4=8D=C3=ADk?= Date: Mon, 24 Aug 2026 12:37:25 +0200 Subject: [PATCH 3/4] fix(mapper): improve serialization of nested objects and handle context in ArrayOfObjectsSerializer --- .../src/Mappers/ObjectToArrayMapper.php | 71 +++++++++++++------ .../Serializers/ArrayOfObjectsSerializer.php | 25 ++++++- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/packages/mapper/src/Mappers/ObjectToArrayMapper.php b/packages/mapper/src/Mappers/ObjectToArrayMapper.php index 0c491870c..68fffb2cb 100644 --- a/packages/mapper/src/Mappers/ObjectToArrayMapper.php +++ b/packages/mapper/src/Mappers/ObjectToArrayMapper.php @@ -14,8 +14,6 @@ use Tempest\Reflection\ClassReflector; use Tempest\Reflection\PropertyReflector; -use function Tempest\Mapper\map; - final readonly class ObjectToArrayMapper implements Mapper { public function __construct( @@ -30,32 +28,63 @@ public function canMap(mixed $from, mixed $to): bool public function map(mixed $from, mixed $to): mixed { - if ($from instanceof JsonSerializable) { - return $from->jsonSerialize(); + $visited = []; + + return $this->mapValue($from, $visited); + } + + /** + * @param array $visited + */ + private function mapValue(mixed $value, array &$visited): mixed + { + if ($value instanceof JsonSerializable) { + return $value->jsonSerialize(); } - if (is_object($from)) { - $class = new ClassReflector($from); + if (! is_object($value)) { + return $value; + } - $mappedProperties = []; + return $this->mapObject($value, $visited); + } - foreach ($class->getPublicProperties() as $property) { - if ($property->hasAttribute(Hidden::class)) { - continue; - } + /** + * @param array $visited + */ + private function mapObject(object $object, array &$visited): mixed + { + $objectId = spl_object_id($object); + + if (isset($visited[$objectId])) { + return $object; + } + + $visited[$objectId] = true; - $propertyName = $this->resolvePropertyName($property); - $propertyValue = $this->resolvePropertyValue($property, $from); - $mappedProperties[$propertyName] = $propertyValue; + $class = new ClassReflector($object); + + $mappedProperties = []; + + foreach ($class->getPublicProperties() as $property) { + if ($property->hasAttribute(Hidden::class)) { + continue; } - } else { - $mappedProperties = $from; + + $propertyName = $this->resolvePropertyName($property); + $propertyValue = $this->resolvePropertyValue($property, $object, $visited); + $mappedProperties[$propertyName] = $propertyValue; } + unset($visited[$objectId]); + return $mappedProperties; } - private function resolvePropertyValue(PropertyReflector $property, object $object): mixed + /** + * @param array $visited + */ + private function resolvePropertyValue(PropertyReflector $property, object $object, array &$visited): mixed { if (! $property->isInitialized($object)) { return null; @@ -69,9 +98,7 @@ private function resolvePropertyValue(PropertyReflector $property, object $objec continue; } - $propertyValue[$key] = map($value) - ->in($this->context) - ->toArray(); + $propertyValue[$key] = $this->mapValue($value, $visited); } return $propertyValue; @@ -82,9 +109,7 @@ private function resolvePropertyValue(PropertyReflector $property, object $objec } if ($propertyValue !== null && is_object($propertyValue)) { - return map($propertyValue) - ->in($this->context) - ->toArray(); + return $this->mapValue($propertyValue, $visited); } return $propertyValue; diff --git a/packages/mapper/src/Serializers/ArrayOfObjectsSerializer.php b/packages/mapper/src/Serializers/ArrayOfObjectsSerializer.php index 1f5c249f1..2262187f8 100644 --- a/packages/mapper/src/Serializers/ArrayOfObjectsSerializer.php +++ b/packages/mapper/src/Serializers/ArrayOfObjectsSerializer.php @@ -4,9 +4,12 @@ namespace Tempest\Mapper\Serializers; +use Tempest\Mapper\ConfigurableSerializer; +use Tempest\Mapper\Context; use Tempest\Mapper\DynamicSerializer; use Tempest\Mapper\Exceptions\ValueCouldNotBeSerialized; use Tempest\Mapper\Mappers\ObjectToArrayMapper; +use Tempest\Mapper\MappingContext; use Tempest\Mapper\Serializer; use Tempest\Reflection\PropertyReflector; use Tempest\Reflection\TypeReflector; @@ -15,8 +18,16 @@ use function Tempest\Mapper\map; #[Priority(Priority::HIGHEST)] -final class ArrayOfObjectsSerializer implements Serializer, DynamicSerializer +final class ArrayOfObjectsSerializer implements Serializer, DynamicSerializer, ConfigurableSerializer { + private readonly Context $context; + + public function __construct( + ?Context $context = null, + ) { + $this->context = $context ?? MappingContext::default(); + } + public static function accepts(PropertyReflector|TypeReflector $input): bool { if ($input instanceof TypeReflector) { @@ -26,16 +37,24 @@ public static function accepts(PropertyReflector|TypeReflector $input): bool return $input->getIterableType() instanceof TypeReflector; } + public static function configure(PropertyReflector|TypeReflector|string $input, Context $context): Serializer + { + return new self($context); + } + public function serialize(mixed $input): array { - if (! is_array($input)) { + if (!is_array($input)) { throw new ValueCouldNotBeSerialized('array'); } $values = []; foreach ($input as $key => $object) { - $values[$key] = map($object)->with(ObjectToArrayMapper::class)->do(); + $values[$key] = map($object) + ->in($this->context) + ->with(ObjectToArrayMapper::class) + ->do(); } return $values; From 24681a212bd9ef25e975e27817238e59291333a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radoslav=20Gren=C4=8D=C3=ADk?= Date: Mon, 24 Aug 2026 12:40:18 +0200 Subject: [PATCH 4/4] fix(mapper): format code style in ArrayOfObjectsSerializer --- packages/mapper/src/Serializers/ArrayOfObjectsSerializer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mapper/src/Serializers/ArrayOfObjectsSerializer.php b/packages/mapper/src/Serializers/ArrayOfObjectsSerializer.php index 2262187f8..253545133 100644 --- a/packages/mapper/src/Serializers/ArrayOfObjectsSerializer.php +++ b/packages/mapper/src/Serializers/ArrayOfObjectsSerializer.php @@ -44,7 +44,7 @@ public static function configure(PropertyReflector|TypeReflector|string $input, public function serialize(mixed $input): array { - if (!is_array($input)) { + if (! is_array($input)) { throw new ValueCouldNotBeSerialized('array'); }