diff --git a/packages/mapper/src/Mappers/ObjectToArrayMapper.php b/packages/mapper/src/Mappers/ObjectToArrayMapper.php index 675d1c012..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); - $propertyName = $this->resolvePropertyName($property); - $propertyValue = $this->resolvePropertyValue($property, $from); - $mappedProperties[$propertyName] = $propertyValue; + if (isset($visited[$objectId])) { + return $object; + } + + $visited[$objectId] = true; + + $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,7 +98,7 @@ private function resolvePropertyValue(PropertyReflector $property, object $objec continue; } - $propertyValue[$key] = map($value)->toArray(); + $propertyValue[$key] = $this->mapValue($value, $visited); } return $propertyValue; @@ -79,6 +108,10 @@ private function resolvePropertyValue(PropertyReflector $property, object $objec return $serializer->serialize($propertyValue); } + if ($propertyValue !== null && is_object($propertyValue)) { + 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..253545133 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,6 +37,11 @@ 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)) { @@ -35,7 +51,10 @@ public function serialize(mixed $input): 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; 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, + ); + } + + #[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/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), + ); + } } 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; + } }