From 8008ab4bf3cd522d955fbea7b51dfaefb0262cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ronald=20M=C3=A1rf=C3=B6ldi?= Date: Thu, 13 Aug 2026 07:41:24 +0000 Subject: [PATCH 1/6] Implement batch processing for serializers with BatchHandlerInterface and related handlers" --- README.md | 54 ++++++++ .../SerializerHandlerCompilerPass.php | 9 ++ src/Handler/HandlerResolver.php | 29 ++++ .../Handlers/BatchHandlerInterface.php | 17 +++ src/Handler/Handlers/EntityIdHandler.php | 32 ++++- src/Service/JsonSerializer.php | 48 ++++++- tests/BatchHandlerTest.php | 101 ++++++++++++++ tests/Dto/BatchDto.php | 33 +++++ tests/Dto/BatchListDto.php | 32 +++++ tests/Dto/EntityIdsDto.php | 36 +++++ tests/EntityIdHandlerTest.php | 124 ++++++++++++++++++ tests/TestApp/Entity/Example.php | 2 +- .../Serializer/RecordingBatchHandler.php | 41 ++++++ tests/config/packages/doctrine.yaml | 4 +- tests/config/services/services.php | 7 + 15 files changed, 564 insertions(+), 5 deletions(-) create mode 100644 src/Handler/Handlers/BatchHandlerInterface.php create mode 100644 tests/BatchHandlerTest.php create mode 100644 tests/Dto/BatchDto.php create mode 100644 tests/Dto/BatchListDto.php create mode 100644 tests/Dto/EntityIdsDto.php create mode 100644 tests/EntityIdHandlerTest.php create mode 100644 tests/TestApp/Serializer/RecordingBatchHandler.php diff --git a/README.md b/README.md index 6a3cf96..cf12fa8 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,60 @@ By default, all handlers have priority 0. Except: `BasicHandler` has highest priority (10) - this handles simple scalar values, so generally you want it to be first. `ObjectHandler` has lowest priority (-1) - this handles nested iterables/objects that no other handler supports. +### Batch handler + +A handler that resolves its value through I/O (a database lookup, an API call) would do it once per item of +a serialized collection. Implement `BatchHandlerInterface` and the serializer will hand you every value of +the collection before it asks you to serialize the first one, so you can resolve them all at once: + +```php +use AnzuSystems\SerializerBundle\Handler\Handlers\AbstractHandler; +use AnzuSystems\SerializerBundle\Handler\Handlers\BatchHandlerInterface; + +final class AuthorHandler extends AbstractHandler implements BatchHandlerInterface +{ + /** @var array */ + private array $authors = []; + + /** + * @param list $values + */ + public function prepareSerializeBatch(array $values): void + { + $missingIds = array_diff(array_filter($values, 'is_int'), array_keys($this->authors)); + foreach ($this->authorRepository->findByIds($missingIds) as $author) { + $this->authors[$author->getId()] = $author; + } + } + + /** + * @param int|null $value + */ + public function serialize(mixed $value, Metadata $metadata, SerializationContext $context): ?array + { + // one query for the whole collection instead of one per item + } +} +``` + +The handler is still forced on the property the usual way, `#[Serialize(handler: AuthorHandler::class)]`. +Serialization output is not affected in any way - preparing a batch only changes how many times the handler +has to go and fetch something. + +Worth knowing before you rely on it: + +- `prepareSerializeBatch()` is called **once per serialized collection**, and a collection nested inside every item of + another collection is therefore prepared once per parent item. +- It may be called **several times per request** (a response can contain more than one collection), so it has + to be idempotent - keep what you already resolved. Keep it in a store that is reset between runs, though: + handlers are container singletons, so an unbounded map on the handler itself outlives the response in a + worker. +- Only `array` and `Doctrine\Common\Collections\Collection` are prepared. A generator or a plain iterator is + skipped, because traversing it twice would consume the data that is about to be serialized; such handlers + fall back to resolving value by value. +- Only handlers forced via `#[Serialize(handler: ...)]` are prepared, not the automatically resolved ones. +- Values arrive in the order of the collection, duplicates and nulls included. Filtering is up to the handler. + ### Automatically generated API documentation via NelmioApiDocBundle Model describer will be automatically registered if [NelmioApiDocBundle](https://github.com/nelmio/NelmioApiDocBundle) is present. diff --git a/src/DependencyInjection/CompilerPass/SerializerHandlerCompilerPass.php b/src/DependencyInjection/CompilerPass/SerializerHandlerCompilerPass.php index 09e06c4..78e2f86 100644 --- a/src/DependencyInjection/CompilerPass/SerializerHandlerCompilerPass.php +++ b/src/DependencyInjection/CompilerPass/SerializerHandlerCompilerPass.php @@ -6,6 +6,7 @@ use AnzuSystems\SerializerBundle\AnzuSystemsSerializerBundle; use AnzuSystems\SerializerBundle\Handler\HandlerResolver; +use AnzuSystems\SerializerBundle\Handler\Handlers\BatchHandlerInterface; use AnzuSystems\SerializerBundle\Handler\Handlers\HandlerInterface; use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; @@ -33,11 +34,19 @@ public function process(ContainerBuilder $container): void $handlerReferences[$handler] = new Reference($handler); } + $batchHandlers = []; + foreach ($handlers as $handler) { + if (is_a($handler, BatchHandlerInterface::class, true)) { + $batchHandlers[$handler] = true; + } + } + $handlerLocator = new ServiceLocatorArgument($handlerReferences); $container ->getDefinition(HandlerResolver::class) ->setArgument('$handlerLocator', $handlerLocator) ->setArgument('$handlers', $handlers) + ->setArgument('$batchHandlers', $batchHandlers) ; } } diff --git a/src/Handler/HandlerResolver.php b/src/Handler/HandlerResolver.php index ab8bd47..1a2770f 100644 --- a/src/Handler/HandlerResolver.php +++ b/src/Handler/HandlerResolver.php @@ -5,6 +5,7 @@ namespace AnzuSystems\SerializerBundle\Handler; use AnzuSystems\SerializerBundle\Exception\SerializerException; +use AnzuSystems\SerializerBundle\Handler\Handlers\BatchHandlerInterface; use AnzuSystems\SerializerBundle\Handler\Handlers\HandlerInterface; use AnzuSystems\SerializerBundle\Metadata\Metadata; use Psr\Container\ContainerExceptionInterface; @@ -13,12 +14,40 @@ final readonly class HandlerResolver { + /** + * @param array, true> $batchHandlers + */ public function __construct( private ContainerInterface $handlerLocator, private array $handlers, + private array $batchHandlers = [], ) { } + public function hasBatchHandlers(): bool + { + return [] !== $this->batchHandlers; + } + + /** + * @throws SerializerException + */ + public function getBatchHandler(string $customHandler): ?BatchHandlerInterface + { + if (false === isset($this->batchHandlers[$customHandler])) { + return null; + } + + try { + /** @var BatchHandlerInterface $handler */ + $handler = $this->handlerLocator->get($customHandler); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface $exception) { + throw new SerializerException('Unable to get handler.', 0, $exception); + } + + return $handler; + } + /** * @throws SerializerException */ diff --git a/src/Handler/Handlers/BatchHandlerInterface.php b/src/Handler/Handlers/BatchHandlerInterface.php new file mode 100644 index 0000000..8f0608c --- /dev/null +++ b/src/Handler/Handlers/BatchHandlerInterface.php @@ -0,0 +1,17 @@ + $values + * + * @throws SerializerException + */ + public function prepareSerializeBatch(array $values): void; +} diff --git a/src/Handler/Handlers/EntityIdHandler.php b/src/Handler/Handlers/EntityIdHandler.php index af356e7..2bed261 100644 --- a/src/Handler/Handlers/EntityIdHandler.php +++ b/src/Handler/Handlers/EntityIdHandler.php @@ -71,10 +71,17 @@ public function deserialize(mixed $value, Metadata $metadata): mixed return null; } if (is_iterable($value)) { - $entities = []; + $entityClass = (string) $metadata->customType; + $ids = []; foreach ($value as $id) { + $ids[] = $id; + } + $this->preloadEntities($ids, $entityClass); + + $entities = []; + foreach ($ids as $id) { /** @psalm-suppress ArgumentTypeCoercion */ - $entity = $this->entityManager->find((string) $metadata->customType, $id); + $entity = $this->entityManager->find($entityClass, $id); if ($entity) { $entities[] = $entity; } @@ -112,6 +119,27 @@ public function describe(string $property, Metadata $metadata): array return $description; } + /** + * One query for the whole list, so that the find() calls that follow are answered from the identity map + * instead of one query per id. + * + * @param list $ids + */ + private function preloadEntities(array $ids, string $entityClass): void + { + $ids = array_filter($ids, static fn (mixed $id): bool => is_int($id) || is_string($id)); + if (count($ids) < 2) { + return; + } + + /** @psalm-suppress ArgumentTypeCoercion */ + $identifier = $this->entityManager->getClassMetadata($entityClass) + ->getSingleIdentifierFieldName(); + /** @psalm-suppress ArgumentTypeCoercion */ + $this->entityManager->getRepository($entityClass) + ->findBy([$identifier => $ids]); + } + private function getOrderedIDs(array $ids, Metadata $metadata): Collection { $uuids = false; diff --git a/src/Service/JsonSerializer.php b/src/Service/JsonSerializer.php index bc85a0a..8a90e28 100644 --- a/src/Service/JsonSerializer.php +++ b/src/Service/JsonSerializer.php @@ -10,6 +10,7 @@ use AnzuSystems\SerializerBundle\Handler\HandlerResolver; use AnzuSystems\SerializerBundle\Metadata\Metadata; use AnzuSystems\SerializerBundle\Metadata\MetadataRegistry; +use Doctrine\Common\Collections\Collection; use JsonException; final class JsonSerializer @@ -44,6 +45,7 @@ public function toArray(object|iterable $data, ?Metadata $metadata = null, ?Seri } if (is_iterable($data)) { + $this->prepareBatches($data); $output = []; foreach ($data as $key => $item) { if (null === $item) { @@ -78,7 +80,7 @@ private function objectToArray(object $data, SerializationContext $context): arr { $output = []; foreach ($this->metadataRegistry->get($data::class)->getAll() as $name => $metadata) { - $value = $metadata->getterSetterStrategy ? $data->{$metadata->getter}() : $data->{$metadata->property}; + $value = $this->getValue($data, $metadata); if (null === $value && !$context->shouldSerializeNull()) { continue; @@ -92,4 +94,48 @@ private function objectToArray(object $data, SerializationContext $context): arr return $output; } + + /** + * @throws SerializerException + */ + private function prepareBatches(iterable $data): void + { + // A generator would be consumed by this pass, so only arrays and collections are prepared. + $traversableTwice = is_array($data) || $data instanceof Collection; + if (false === $traversableTwice || false === $this->handlerResolver->hasBatchHandlers()) { + return; + } + + $handlers = []; + $values = []; + foreach ($data as $item) { + if (false === is_object($item)) { + continue; + } + + foreach ($this->metadataRegistry->get($item::class)->getAll() as $metadata) { + $handlerClass = $metadata->customHandler; + if (null === $handlerClass) { + continue; + } + + $handler = $this->handlerResolver->getBatchHandler($handlerClass); + if (null === $handler) { + continue; + } + + $handlers[$handlerClass] = $handler; + $values[$handlerClass][] = $this->getValue($item, $metadata); + } + } + + foreach ($values as $handlerClass => $handlerValues) { + $handlers[$handlerClass]->prepareSerializeBatch($handlerValues); + } + } + + private function getValue(object $data, Metadata $metadata): mixed + { + return $metadata->getterSetterStrategy ? $data->{$metadata->getter}() : $data->{$metadata->property}; + } } diff --git a/tests/BatchHandlerTest.php b/tests/BatchHandlerTest.php new file mode 100644 index 0000000..6c4c95b --- /dev/null +++ b/tests/BatchHandlerTest.php @@ -0,0 +1,101 @@ +get(RecordingBatchHandler::class); + $this->handler = $handler; + } + + /** + * @param list> $expectedBatches + * + * @throws SerializerException + */ + #[DataProvider('data')] + public function testBatchIsPreparedWithoutChangingTheOutput( + object|iterable $data, + array $expectedBatches, + string $expectedJson, + ): void { + $serialized = $this->serializer->serialize($data); + + self::assertSame($expectedBatches, $this->handler->getBatches()); + self::assertSame($expectedJson, $serialized); + } + + public static function data(): iterable + { + $values = [['first', 'second', 'third']]; + + yield 'array' => [self::items(), $values, self::COLLECTION_JSON]; + yield 'collection' => [new ArrayCollection(self::items()), $values, self::COLLECTION_JSON]; + yield 'collection inside an object' => [ + new BatchListDto(self::items()), + $values, + '{"data":' . self::COLLECTION_JSON . '}', + ]; + yield 'single object' => [new BatchDto('first', 'First'), [], '{"code":"first","label":"First"}']; + yield 'generator' => [self::itemGenerator(), [], self::COLLECTION_JSON]; + yield 'duplicate values' => [ + [new BatchDto('same', 'One'), new BatchDto('same', 'Two')], + [['same', 'same']], + '[{"code":"same","label":"One"},{"code":"same","label":"Two"}]', + ]; + } + + /** + * @throws SerializerException + */ + public function testEachSerializedCollectionGetsItsOwnBatch(): void + { + $this->serializer->serialize(self::items()); + $this->serializer->serialize([new BatchDto('fourth', 'Fourth')]); + + self::assertSame([['first', 'second', 'third'], ['fourth']], $this->handler->getBatches()); + } + + /** + * @return list + */ + private static function items(): array + { + return [ + new BatchDto('first', 'First'), + new BatchDto('second', 'Second'), + new BatchDto('third', 'Third'), + ]; + } + + /** + * @return Generator + */ + private static function itemGenerator(): Generator + { + yield from self::items(); + } +} diff --git a/tests/Dto/BatchDto.php b/tests/Dto/BatchDto.php new file mode 100644 index 0000000..9751103 --- /dev/null +++ b/tests/Dto/BatchDto.php @@ -0,0 +1,33 @@ +code = $code; + $this->label = $label; + } + + public function getCode(): string + { + return $this->code; + } + + public function getLabel(): string + { + return $this->label; + } +} diff --git a/tests/Dto/BatchListDto.php b/tests/Dto/BatchListDto.php new file mode 100644 index 0000000..b8286dd --- /dev/null +++ b/tests/Dto/BatchListDto.php @@ -0,0 +1,32 @@ + + */ + #[Serialize(type: BatchDto::class)] + private array $data; + + /** + * @param list $data + */ + public function __construct(array $data) + { + $this->data = $data; + } + + /** + * @return list + */ + public function getData(): array + { + return $this->data; + } +} diff --git a/tests/Dto/EntityIdsDto.php b/tests/Dto/EntityIdsDto.php new file mode 100644 index 0000000..454e251 --- /dev/null +++ b/tests/Dto/EntityIdsDto.php @@ -0,0 +1,36 @@ + + */ + #[Serialize(handler: EntityIdHandler::class, type: Example::class)] + private array $examples = []; + + /** + * @return list + */ + public function getExamples(): array + { + return $this->examples; + } + + /** + * @param list $examples + */ + public function setExamples(array $examples): self + { + $this->examples = $examples; + + return $this; + } +} diff --git a/tests/EntityIdHandlerTest.php b/tests/EntityIdHandlerTest.php new file mode 100644 index 0000000..2e94772 --- /dev/null +++ b/tests/EntityIdHandlerTest.php @@ -0,0 +1,124 @@ +get('doctrine.orm.entity_manager'); + $this->entityManager = $entityManager; + /** @var DebugDataHolder $debugDataHolder */ + $debugDataHolder = self::getContainer()->get('doctrine.debug_data_holder'); + $this->debugDataHolder = $debugDataHolder; + + foreach ([self::FIRST_ID, self::SECOND_ID, self::THIRD_ID] as $id) { + $this->entityManager->persist(new Example()->setId($id)->setName('example-' . $id)); + } + $this->entityManager->flush(); + $this->entityManager->clear(); + $this->debugDataHolder->reset(); + } + + protected function tearDown(): void + { + $this->entityManager->createQuery('DELETE FROM ' . Example::class)->execute(); + $this->entityManager->clear(); + + parent::tearDown(); + } + + /** + * @throws SerializerException + */ + public function testListOfIdsIsFetchedInOneQuery(): void + { + $dto = $this->deserialize([self::FIRST_ID, self::SECOND_ID, self::THIRD_ID]); + + self::assertSame(1, $this->queryCount(), 'Three ids must cost one query, not one each'); + self::assertSame([self::FIRST_ID, self::SECOND_ID, self::THIRD_ID], $this->ids($dto)); + } + + /** + * @throws SerializerException + */ + public function testOrderFollowsTheRequestedIds(): void + { + $dto = $this->deserialize([self::THIRD_ID, self::FIRST_ID, self::SECOND_ID]); + + self::assertSame([self::THIRD_ID, self::FIRST_ID, self::SECOND_ID], $this->ids($dto)); + } + + /** + * @throws SerializerException + */ + public function testDuplicatesAreKeptAndUnknownIdsAreSkipped(): void + { + $dto = $this->deserialize([self::FIRST_ID, self::MISSING_ID, self::FIRST_ID]); + + self::assertSame([self::FIRST_ID, self::FIRST_ID], $this->ids($dto)); + } + + /** + * @throws SerializerException + */ + public function testEmptyListCostsNoQuery(): void + { + $dto = $this->deserialize([]); + + self::assertSame(0, $this->queryCount()); + self::assertSame([], $this->ids($dto)); + } + + /** + * @param list $ids + * + * @throws SerializerException + */ + private function deserialize(array $ids): EntityIdsDto + { + /** @var EntityIdsDto $dto */ + $dto = $this->serializer->deserialize( + json_encode(['examples' => $ids], JSON_THROW_ON_ERROR), + EntityIdsDto::class, + ); + + return $dto; + } + + private function queryCount(): int + { + return count($this->debugDataHolder->getData()['default'] ?? []); + } + + /** + * @return list + */ + private function ids(EntityIdsDto $dto): array + { + return array_map(static fn (Example $example): int => $example->getId(), $dto->getExamples()); + } +} diff --git a/tests/TestApp/Entity/Example.php b/tests/TestApp/Entity/Example.php index bb20bad..185001d 100644 --- a/tests/TestApp/Entity/Example.php +++ b/tests/TestApp/Entity/Example.php @@ -32,7 +32,7 @@ class Example #[Serialize] private ExampleBackedEnum $place = ExampleBackedEnum::First; - #[ORM\Column(enumType: ExampleUnitEnum::class)] + // Not persisted: Doctrine maps backed enums only; kept to cover unit enum serialization. #[Serialize] private ExampleUnitEnum $color = ExampleUnitEnum::Red; diff --git a/tests/TestApp/Serializer/RecordingBatchHandler.php b/tests/TestApp/Serializer/RecordingBatchHandler.php new file mode 100644 index 0000000..fa0e092 --- /dev/null +++ b/tests/TestApp/Serializer/RecordingBatchHandler.php @@ -0,0 +1,41 @@ +> + */ + private array $batches = []; + + public function serialize(mixed $value, Metadata $metadata, SerializationContext $context): mixed + { + return $value; + } + + public function deserialize(mixed $value, Metadata $metadata): mixed + { + return $value; + } + + public function prepareSerializeBatch(array $values): void + { + $this->batches[] = $values; + } + + /** + * @return list> + */ + public function getBatches(): array + { + return $this->batches; + } +} diff --git a/tests/config/packages/doctrine.yaml b/tests/config/packages/doctrine.yaml index 73ed907..c9c245d 100644 --- a/tests/config/packages/doctrine.yaml +++ b/tests/config/packages/doctrine.yaml @@ -1,6 +1,8 @@ doctrine: dbal: url: '%env(resolve:DB_BUNDLE_URL)%' + profiling: true + profiling_collect_backtrace: false orm: auto_generate_proxy_classes: true enable_native_lazy_objects: true @@ -9,5 +11,5 @@ doctrine: mappings: App: dir: '%kernel.project_dir%/tests/TestApp/Entity' - prefix: 'AnzuSystems\CommonBundle\Tests\TestApp\Entity' + prefix: 'AnzuSystems\SerializerBundle\Tests\TestApp\Entity' type: attribute diff --git a/tests/config/services/services.php b/tests/config/services/services.php index 61653ac..2aa0c9f 100644 --- a/tests/config/services/services.php +++ b/tests/config/services/services.php @@ -5,6 +5,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use AnzuSystems\SerializerBundle\Tests\TestApp\Controller\DummyController; +use AnzuSystems\SerializerBundle\Tests\TestApp\Serializer\RecordingBatchHandler; return static function (ContainerConfigurator $configurator): void { $services = $configurator->services(); @@ -13,4 +14,10 @@ ->autowire(true) ->autoconfigure(true) ; + + $services->set(RecordingBatchHandler::class) + ->autowire(true) + ->autoconfigure(true) + ->public() + ; }; From 300169da3b1b55d1696c06682df2743c2525778d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ronald=20M=C3=A1rf=C3=B6ldi?= Date: Thu, 13 Aug 2026 09:14:58 +0000 Subject: [PATCH 2/6] Enhance batch serialization by adding metadata and context parameters to handlers --- README.md | 10 +++++-- docker-compose.yml | 1 - .../Handlers/BatchHandlerInterface.php | 6 ++-- src/Service/JsonSerializer.php | 17 +++++++---- tests/BatchHandlerTest.php | 27 +++++++++++++++++ tests/Dto/BatchOtherDto.php | 24 +++++++++++++++ .../Serializer/RecordingBatchHandler.php | 30 ++++++++++++++++++- 7 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 tests/Dto/BatchOtherDto.php diff --git a/README.md b/README.md index cf12fa8..589e678 100644 --- a/README.md +++ b/README.md @@ -200,8 +200,10 @@ a serialized collection. Implement `BatchHandlerInterface` and the serializer wi the collection before it asks you to serialize the first one, so you can resolve them all at once: ```php +use AnzuSystems\SerializerBundle\Context\SerializationContext; use AnzuSystems\SerializerBundle\Handler\Handlers\AbstractHandler; use AnzuSystems\SerializerBundle\Handler\Handlers\BatchHandlerInterface; +use AnzuSystems\SerializerBundle\Metadata\Metadata; final class AuthorHandler extends AbstractHandler implements BatchHandlerInterface { @@ -211,7 +213,7 @@ final class AuthorHandler extends AbstractHandler implements BatchHandlerInterfa /** * @param list $values */ - public function prepareSerializeBatch(array $values): void + public function prepareSerializeBatch(array $values, Metadata $metadata, SerializationContext $context): void { $missingIds = array_diff(array_filter($values, 'is_int'), array_keys($this->authors)); foreach ($this->authorRepository->findByIds($missingIds) as $author) { @@ -235,8 +237,10 @@ has to go and fetch something. Worth knowing before you rely on it: -- `prepareSerializeBatch()` is called **once per serialized collection**, and a collection nested inside every item of - another collection is therefore prepared once per parent item. +- `prepareSerializeBatch()` is called **once per property per serialized collection**, and it receives the + `Metadata` of that property, so a handler parametrized by metadata (`customType`, `strategy`, `orderBy`) knows + what it is preparing. A collection whose items are of different classes therefore gets one call per class. +- A collection nested inside every item of another collection is prepared once per parent item. - It may be called **several times per request** (a response can contain more than one collection), so it has to be idempotent - keep what you already resolved. Keep it in a store that is reset between runs, though: handlers are container singletons, so an unbounded map on the handler itself outlives the response in a diff --git a/docker-compose.yml b/docker-compose.yml index 8b674ef..20cd34b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,6 @@ services: mysql: image: mysql:8 command: - - --default-authentication-plugin=mysql_native_password - --disable-log-bin env_file: - .env.docker.dist diff --git a/src/Handler/Handlers/BatchHandlerInterface.php b/src/Handler/Handlers/BatchHandlerInterface.php index 8f0608c..3b95adf 100644 --- a/src/Handler/Handlers/BatchHandlerInterface.php +++ b/src/Handler/Handlers/BatchHandlerInterface.php @@ -4,14 +4,16 @@ namespace AnzuSystems\SerializerBundle\Handler\Handlers; +use AnzuSystems\SerializerBundle\Context\SerializationContext; use AnzuSystems\SerializerBundle\Exception\SerializerException; +use AnzuSystems\SerializerBundle\Metadata\Metadata; interface BatchHandlerInterface extends HandlerInterface { /** - * @param list $values + * @param list $values every value the following serialize() calls will receive, for one property * * @throws SerializerException */ - public function prepareSerializeBatch(array $values): void; + public function prepareSerializeBatch(array $values, Metadata $metadata, SerializationContext $context): void; } diff --git a/src/Service/JsonSerializer.php b/src/Service/JsonSerializer.php index 8a90e28..e7da9b1 100644 --- a/src/Service/JsonSerializer.php +++ b/src/Service/JsonSerializer.php @@ -45,7 +45,7 @@ public function toArray(object|iterable $data, ?Metadata $metadata = null, ?Seri } if (is_iterable($data)) { - $this->prepareBatches($data); + $this->prepareBatches($data, $context); $output = []; foreach ($data as $key => $item) { if (null === $item) { @@ -98,7 +98,7 @@ private function objectToArray(object $data, SerializationContext $context): arr /** * @throws SerializerException */ - private function prepareBatches(iterable $data): void + private function prepareBatches(iterable $data, SerializationContext $context): void { // A generator would be consumed by this pass, so only arrays and collections are prepared. $traversableTwice = is_array($data) || $data instanceof Collection; @@ -107,6 +107,7 @@ private function prepareBatches(iterable $data): void } $handlers = []; + $metadataList = []; $values = []; foreach ($data as $item) { if (false === is_object($item)) { @@ -124,13 +125,17 @@ private function prepareBatches(iterable $data): void continue; } - $handlers[$handlerClass] = $handler; - $values[$handlerClass][] = $this->getValue($item, $metadata); + // MetadataRegistry keeps one instance per class and property, so its identity buckets the + // values of one property together and keeps differently configured properties apart. + $bucket = spl_object_id($metadata); + $handlers[$bucket] = $handler; + $metadataList[$bucket] = $metadata; + $values[$bucket][] = $this->getValue($item, $metadata); } } - foreach ($values as $handlerClass => $handlerValues) { - $handlers[$handlerClass]->prepareSerializeBatch($handlerValues); + foreach ($values as $bucket => $bucketValues) { + $handlers[$bucket]->prepareSerializeBatch($bucketValues, $metadataList[$bucket], $context); } } diff --git a/tests/BatchHandlerTest.php b/tests/BatchHandlerTest.php index 6c4c95b..a0b1276 100644 --- a/tests/BatchHandlerTest.php +++ b/tests/BatchHandlerTest.php @@ -4,9 +4,11 @@ namespace AnzuSystems\SerializerBundle\Tests; +use AnzuSystems\SerializerBundle\Context\SerializationContext; use AnzuSystems\SerializerBundle\Exception\SerializerException; use AnzuSystems\SerializerBundle\Tests\Dto\BatchDto; use AnzuSystems\SerializerBundle\Tests\Dto\BatchListDto; +use AnzuSystems\SerializerBundle\Tests\Dto\BatchOtherDto; use AnzuSystems\SerializerBundle\Tests\TestApp\Serializer\RecordingBatchHandler; use Doctrine\Common\Collections\ArrayCollection; use Exception; @@ -79,6 +81,31 @@ public function testEachSerializedCollectionGetsItsOwnBatch(): void self::assertSame([['first', 'second', 'third'], ['fourth']], $this->handler->getBatches()); } + /** + * @throws SerializerException + */ + public function testEachPreparedPropertyGetsItsOwnBatchWithItsMetadata(): void + { + $this->serializer->serialize([ + new BatchDto('first', 'First'), + new BatchOtherDto('other'), + new BatchDto('second', 'Second'), + ]); + + self::assertSame([['first', 'second'], ['other']], $this->handler->getBatches()); + self::assertSame(['code', 'ref'], $this->handler->getBatchedProperties()); + } + + /** + * @throws SerializerException + */ + public function testBatchReceivesTheSerializationContext(): void + { + $this->serializer->serialize(self::items(), SerializationContext::create()->setSerializeNulls(false)); + + self::assertSame([false], $this->handler->getBatchedNullStrategies()); + } + /** * @return list */ diff --git a/tests/Dto/BatchOtherDto.php b/tests/Dto/BatchOtherDto.php new file mode 100644 index 0000000..c2005c8 --- /dev/null +++ b/tests/Dto/BatchOtherDto.php @@ -0,0 +1,24 @@ +ref = $ref; + } + + public function getRef(): string + { + return $this->ref; + } +} diff --git a/tests/TestApp/Serializer/RecordingBatchHandler.php b/tests/TestApp/Serializer/RecordingBatchHandler.php index fa0e092..e0032a6 100644 --- a/tests/TestApp/Serializer/RecordingBatchHandler.php +++ b/tests/TestApp/Serializer/RecordingBatchHandler.php @@ -16,6 +16,16 @@ final class RecordingBatchHandler extends AbstractHandler implements BatchHandle */ private array $batches = []; + /** + * @var list + */ + private array $batchedProperties = []; + + /** + * @var list + */ + private array $batchedNullStrategies = []; + public function serialize(mixed $value, Metadata $metadata, SerializationContext $context): mixed { return $value; @@ -26,9 +36,11 @@ public function deserialize(mixed $value, Metadata $metadata): mixed return $value; } - public function prepareSerializeBatch(array $values): void + public function prepareSerializeBatch(array $values, Metadata $metadata, SerializationContext $context): void { $this->batches[] = $values; + $this->batchedProperties[] = (string) $metadata->property; + $this->batchedNullStrategies[] = $context->shouldSerializeNull(); } /** @@ -38,4 +50,20 @@ public function getBatches(): array { return $this->batches; } + + /** + * @return list + */ + public function getBatchedProperties(): array + { + return $this->batchedProperties; + } + + /** + * @return list + */ + public function getBatchedNullStrategies(): array + { + return $this->batchedNullStrategies; + } } From 2d249164ba716da83d6ee8f726b7a68523e04900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ronald=20M=C3=A1rf=C3=B6ldi?= Date: Thu, 13 Aug 2026 09:54:24 +0000 Subject: [PATCH 3/6] Refactor serialization handling: rename batchedNullStrategies to batchedSerializeNulls and update related methods --- .../Handlers/BatchHandlerInterface.php | 2 +- src/Handler/Handlers/EntityIdHandler.php | 56 +++++++++++++++---- src/Service/JsonSerializer.php | 3 +- tests/BatchHandlerTest.php | 2 +- tests/EntityIdHandlerTest.php | 26 +++++++++ .../Serializer/RecordingBatchHandler.php | 8 +-- 6 files changed, 78 insertions(+), 19 deletions(-) diff --git a/src/Handler/Handlers/BatchHandlerInterface.php b/src/Handler/Handlers/BatchHandlerInterface.php index 3b95adf..2853d9e 100644 --- a/src/Handler/Handlers/BatchHandlerInterface.php +++ b/src/Handler/Handlers/BatchHandlerInterface.php @@ -11,7 +11,7 @@ interface BatchHandlerInterface extends HandlerInterface { /** - * @param list $values every value the following serialize() calls will receive, for one property + * @param list $values * * @throws SerializerException */ diff --git a/src/Handler/Handlers/EntityIdHandler.php b/src/Handler/Handlers/EntityIdHandler.php index 2bed261..567a99d 100644 --- a/src/Handler/Handlers/EntityIdHandler.php +++ b/src/Handler/Handlers/EntityIdHandler.php @@ -76,10 +76,14 @@ public function deserialize(mixed $value, Metadata $metadata): mixed foreach ($value as $id) { $ids[] = $id; } - $this->preloadEntities($ids, $entityClass); + $absentIds = $this->preloadEntities($ids, $entityClass); $entities = []; foreach ($ids as $id) { + if ((is_int($id) || is_string($id)) && isset($absentIds[$id])) { + continue; + } + /** @psalm-suppress ArgumentTypeCoercion */ $entity = $this->entityManager->find($entityClass, $id); if ($entity) { @@ -120,24 +124,54 @@ public function describe(string $property, Metadata $metadata): array } /** - * One query for the whole list, so that the find() calls that follow are answered from the identity map - * instead of one query per id. + * One query for the whole list, so the find() calls that follow hit the identity map. Ids it did not bring + * back are returned, because Doctrine has no negative cache and find() would query each of them again. * * @param list $ids + * + * @return array */ - private function preloadEntities(array $ids, string $entityClass): void + private function preloadEntities(array $ids, string $entityClass): array { - $ids = array_filter($ids, static fn (mixed $id): bool => is_int($id) || is_string($id)); - if (count($ids) < 2) { - return; + /** @psalm-suppress ArgumentTypeCoercion */ + $classMetadata = $this->entityManager->getClassMetadata($entityClass); + $identifier = $classMetadata->getSingleIdentifierFieldName(); + $rootEntityName = $classMetadata->rootEntityName; + + $unmanagedIds = $this->filterUnmanagedIds($ids, $identifier, $rootEntityName); + if (count($unmanagedIds) < 2) { + return []; } - /** @psalm-suppress ArgumentTypeCoercion */ - $identifier = $this->entityManager->getClassMetadata($entityClass) - ->getSingleIdentifierFieldName(); /** @psalm-suppress ArgumentTypeCoercion */ $this->entityManager->getRepository($entityClass) - ->findBy([$identifier => $ids]); + ->findBy([$identifier => $unmanagedIds]); + + return array_flip($this->filterUnmanagedIds($unmanagedIds, $identifier, $rootEntityName)); + } + + /** + * @param list $ids + * + * @return list without duplicates + */ + private function filterUnmanagedIds(array $ids, string $identifier, string $rootEntityName): array + { + $unitOfWork = $this->entityManager->getUnitOfWork(); + + $unmanagedIds = []; + foreach ($ids as $id) { + if (false === is_int($id) && false === is_string($id)) { + continue; + } + // The same lookup find() does, so an id it answers from the identity map is never queried again. + /** @psalm-suppress ArgumentTypeCoercion */ + if (false === $unitOfWork->tryGetById([$identifier => $id], $rootEntityName)) { + $unmanagedIds[$id] = $id; + } + } + + return array_values($unmanagedIds); } private function getOrderedIDs(array $ids, Metadata $metadata): Collection diff --git a/src/Service/JsonSerializer.php b/src/Service/JsonSerializer.php index e7da9b1..f77700c 100644 --- a/src/Service/JsonSerializer.php +++ b/src/Service/JsonSerializer.php @@ -125,8 +125,7 @@ private function prepareBatches(iterable $data, SerializationContext $context): continue; } - // MetadataRegistry keeps one instance per class and property, so its identity buckets the - // values of one property together and keeps differently configured properties apart. + // MetadataRegistry keeps one instance per class and property, so its identity is a stable bucket. $bucket = spl_object_id($metadata); $handlers[$bucket] = $handler; $metadataList[$bucket] = $metadata; diff --git a/tests/BatchHandlerTest.php b/tests/BatchHandlerTest.php index a0b1276..8b829c7 100644 --- a/tests/BatchHandlerTest.php +++ b/tests/BatchHandlerTest.php @@ -103,7 +103,7 @@ public function testBatchReceivesTheSerializationContext(): void { $this->serializer->serialize(self::items(), SerializationContext::create()->setSerializeNulls(false)); - self::assertSame([false], $this->handler->getBatchedNullStrategies()); + self::assertSame([false], $this->handler->getBatchedSerializeNulls()); } /** diff --git a/tests/EntityIdHandlerTest.php b/tests/EntityIdHandlerTest.php index 2e94772..91875b5 100644 --- a/tests/EntityIdHandlerTest.php +++ b/tests/EntityIdHandlerTest.php @@ -82,6 +82,32 @@ public function testDuplicatesAreKeptAndUnknownIdsAreSkipped(): void self::assertSame([self::FIRST_ID, self::FIRST_ID], $this->ids($dto)); } + /** + * @throws SerializerException + */ + public function testUnknownIdsCostNoQueryOfTheirOwn(): void + { + $dto = $this->deserialize([self::FIRST_ID, self::MISSING_ID, self::SECOND_ID]); + + self::assertSame(1, $this->queryCount(), 'A missing id must not cost a query of its own'); + self::assertSame([self::FIRST_ID, self::SECOND_ID], $this->ids($dto)); + } + + /** + * @throws SerializerException + */ + public function testAlreadyLoadedIdsCostNoQuery(): void + { + $this->entityManager->getRepository(Example::class) + ->findBy(['id' => [self::FIRST_ID, self::SECOND_ID, self::THIRD_ID]]); + $this->debugDataHolder->reset(); + + $dto = $this->deserialize([self::FIRST_ID, self::SECOND_ID, self::THIRD_ID]); + + self::assertSame(0, $this->queryCount()); + self::assertSame([self::FIRST_ID, self::SECOND_ID, self::THIRD_ID], $this->ids($dto)); + } + /** * @throws SerializerException */ diff --git a/tests/TestApp/Serializer/RecordingBatchHandler.php b/tests/TestApp/Serializer/RecordingBatchHandler.php index e0032a6..a9e68dc 100644 --- a/tests/TestApp/Serializer/RecordingBatchHandler.php +++ b/tests/TestApp/Serializer/RecordingBatchHandler.php @@ -24,7 +24,7 @@ final class RecordingBatchHandler extends AbstractHandler implements BatchHandle /** * @var list */ - private array $batchedNullStrategies = []; + private array $batchedSerializeNulls = []; public function serialize(mixed $value, Metadata $metadata, SerializationContext $context): mixed { @@ -40,7 +40,7 @@ public function prepareSerializeBatch(array $values, Metadata $metadata, Seriali { $this->batches[] = $values; $this->batchedProperties[] = (string) $metadata->property; - $this->batchedNullStrategies[] = $context->shouldSerializeNull(); + $this->batchedSerializeNulls[] = $context->shouldSerializeNull(); } /** @@ -62,8 +62,8 @@ public function getBatchedProperties(): array /** * @return list */ - public function getBatchedNullStrategies(): array + public function getBatchedSerializeNulls(): array { - return $this->batchedNullStrategies; + return $this->batchedSerializeNulls; } } From 8512ef04cd3bbffc9e921a7d50b90e1c3748e9fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ronald=20M=C3=A1rf=C3=B6ldi?= Date: Thu, 13 Aug 2026 10:03:26 +0000 Subject: [PATCH 4/6] Fix ECS --- src/Handler/Handlers/EntityIdHandler.php | 6 ++++-- src/OpenApi/SerializerModelDescriber.php | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Handler/Handlers/EntityIdHandler.php b/src/Handler/Handlers/EntityIdHandler.php index 567a99d..8dace1d 100644 --- a/src/Handler/Handlers/EntityIdHandler.php +++ b/src/Handler/Handlers/EntityIdHandler.php @@ -187,7 +187,8 @@ private function getOrderedIDs(array $ids, Metadata $metadata): Collection return $id; }, $ids); /** @psalm-suppress ArgumentTypeCoercion */ - $dqb = $this->entityManager->getRepository((string) $metadata->customType)->createQueryBuilder('entity'); + $dqb = $this->entityManager->getRepository((string) $metadata->customType) + ->createQueryBuilder('entity'); $dqb ->select('entity.id') ->where('entity.id IN (:ids)') @@ -205,7 +206,8 @@ private function getOrderedIDs(array $ids, Metadata $metadata): Collection } return $id; - }, $dqb->getQuery()->getSingleColumnResult()); + }, $dqb->getQuery() + ->getSingleColumnResult()); return new ArrayCollection($resultIds); } diff --git a/src/OpenApi/SerializerModelDescriber.php b/src/OpenApi/SerializerModelDescriber.php index 3cf7f3e..342f54b 100644 --- a/src/OpenApi/SerializerModelDescriber.php +++ b/src/OpenApi/SerializerModelDescriber.php @@ -68,7 +68,8 @@ public function describe(Model $model, Schema $schema): void if (is_string($metadata->property) && $typeInfo instanceof ObjectType && false === empty($typeInfo->getClassName())) { /** @psalm-suppress ArgumentTypeCoercion */ $propertyReflection = new ReflectionProperty($typeInfo->getClassName(), $metadata->property); - $this->getSymfonyConstraintAnnotationReader()->updateProperty($propertyReflection, $property); + $this->getSymfonyConstraintAnnotationReader() + ->updateProperty($propertyReflection, $property); $this->addDocBlockDescription($propertyReflection, $property); } @@ -130,7 +131,7 @@ private function addDocBlockDescription(ReflectionProperty|ReflectionMethod $ref } /** - * @throws ReflectionException | SerializerException + * @throws ReflectionException|SerializerException */ private function describeNested(string $property, array $description): ?Property { @@ -152,7 +153,7 @@ private function describeNested(string $property, array $description): ?Property } /** - * @throws ReflectionException | SerializerException + * @throws ReflectionException|SerializerException */ private function describeNestedItems(array &$description): void { @@ -180,7 +181,8 @@ private function getMetadata(Model $model): array $className = $typeInfo->getClassName(); if (class_exists($className)) { try { - return $this->metadataRegistry->get($className)->getAll(); + return $this->metadataRegistry->get($className) + ->getAll(); } catch (SerializerException) { return []; } From 55b34e285ca7686940260ab83f01cccbe0dc5dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ronald=20M=C3=A1rf=C3=B6ldi?= Date: Thu, 13 Aug 2026 12:52:34 +0000 Subject: [PATCH 5/6] Implement batch serialization and deserialization interfaces with improved handling for multiple properties and collections --- README.md | 67 ++++++++--- .../SerializerHandlerCompilerPass.php | 9 -- src/Handler/BatchItem.php | 16 +++ src/Handler/HandlerResolver.php | 21 +--- .../BatchDeserializeHandlerInterface.php | 16 +++ ...php => BatchSerializeHandlerInterface.php} | 8 +- src/Handler/Handlers/EntityIdHandler.php | 106 +++++++++++------- src/Service/JsonDeserializer.php | 67 +++++++++++ src/Service/JsonSerializer.php | 57 ++++++---- tests/BatchHandlerTest.php | 17 ++- tests/Dto/BatchDto.php | 8 ++ tests/Dto/EntityIdsMultiPropDto.php | 75 +++++++++++++ tests/EntityIdHandlerTest.php | 52 ++++++++- .../Serializer/RecordingBatchHandler.php | 18 +-- 14 files changed, 414 insertions(+), 123 deletions(-) create mode 100644 src/Handler/BatchItem.php create mode 100644 src/Handler/Handlers/BatchDeserializeHandlerInterface.php rename src/Handler/Handlers/{BatchHandlerInterface.php => BatchSerializeHandlerInterface.php} (50%) create mode 100644 tests/Dto/EntityIdsMultiPropDto.php diff --git a/README.md b/README.md index 589e678..617472f 100644 --- a/README.md +++ b/README.md @@ -193,30 +193,34 @@ By default, all handlers have priority 0. Except: `BasicHandler` has highest priority (10) - this handles simple scalar values, so generally you want it to be first. `ObjectHandler` has lowest priority (-1) - this handles nested iterables/objects that no other handler supports. -### Batch handler +### Batch handlers A handler that resolves its value through I/O (a database lookup, an API call) would do it once per item of -a serialized collection. Implement `BatchHandlerInterface` and the serializer will hand you every value of -the collection before it asks you to serialize the first one, so you can resolve them all at once: +a serialized collection, and once per property of a deserialized object. Two interfaces let it do the I/O once +for the whole batch instead: `BatchSerializeHandlerInterface` for the way out, `BatchDeserializeHandlerInterface` +for the way in. + +#### Serialization + +Implement `BatchSerializeHandlerInterface` and the serializer will hand you every value of the collection as a +`BatchItem` before it asks you to serialize the first one, so you can resolve them all at once: ```php use AnzuSystems\SerializerBundle\Context\SerializationContext; +use AnzuSystems\SerializerBundle\Handler\BatchItem; use AnzuSystems\SerializerBundle\Handler\Handlers\AbstractHandler; -use AnzuSystems\SerializerBundle\Handler\Handlers\BatchHandlerInterface; +use AnzuSystems\SerializerBundle\Handler\Handlers\BatchSerializeHandlerInterface; use AnzuSystems\SerializerBundle\Metadata\Metadata; -final class AuthorHandler extends AbstractHandler implements BatchHandlerInterface +final class AuthorHandler extends AbstractHandler implements BatchSerializeHandlerInterface { /** @var array */ private array $authors = []; - /** - * @param list $values - */ - public function prepareSerializeBatch(array $values, Metadata $metadata, SerializationContext $context): void + public function prepareSerializeBatch(SerializationContext $context, BatchItem ...$items): void { - $missingIds = array_diff(array_filter($values, 'is_int'), array_keys($this->authors)); - foreach ($this->authorRepository->findByIds($missingIds) as $author) { + $ids = array_filter(array_map(static fn (BatchItem $item): mixed => $item->value, $items), 'is_int'); + foreach ($this->authorRepository->findByIds(array_diff($ids, array_keys($this->authors))) as $author) { $this->authors[$author->getId()] = $author; } } @@ -237,9 +241,10 @@ has to go and fetch something. Worth knowing before you rely on it: -- `prepareSerializeBatch()` is called **once per property per serialized collection**, and it receives the - `Metadata` of that property, so a handler parametrized by metadata (`customType`, `strategy`, `orderBy`) knows - what it is preparing. A collection whose items are of different classes therefore gets one call per class. +- `prepareSerializeBatch()` is called **once per serialized collection**, with every value the handler owns across + every item, each carrying the `Metadata` of the property it came from - so a handler parametrized by metadata + (`customType`, `strategy`, `orderBy`) can group the items itself instead of being handed them pre-split. +- The values it reads are reused when serializing, so a getter behind a batched property is called once per item. - A collection nested inside every item of another collection is prepared once per parent item. - It may be called **several times per request** (a response can contain more than one collection), so it has to be idempotent - keep what you already resolved. Keep it in a store that is reset between runs, though: @@ -251,6 +256,40 @@ Worth knowing before you rely on it: - Only handlers forced via `#[Serialize(handler: ...)]` are prepared, not the automatically resolved ones. - Values arrive in the order of the collection, duplicates and nulls included. Filtering is up to the handler. +#### Deserialization + +Implement `BatchDeserializeHandlerInterface` and you get every property of the object about to be deserialized +that this handler is responsible for, each as a `BatchItem` carrying the raw value and its `Metadata`, before the +first `deserialize()` call: + +```php +use AnzuSystems\SerializerBundle\Handler\BatchItem; +use AnzuSystems\SerializerBundle\Handler\Handlers\BatchDeserializeHandlerInterface; + +final class AuthorHandler extends AbstractHandler implements BatchDeserializeHandlerInterface +{ + public function prepareDeserializeBatch(BatchItem ...$items): void + { + // group by whatever the handler keys its lookups on, then one fetch per group + } +} +``` + +The point is the grouping the handler itself controls: three properties pointing at the same target type share +one lookup, and a property holding a single value joins the same batch instead of costing a lookup of its own. + +Worth knowing: + +- The pass covers **one object at a time**, the properties present in the incoming data that this handler owns. + A list payload (`deserialize($json, Foo::class, [])`) is prepared for the whole list first, so N items cost one + batch, not N. Nested objects are prepared when their own turn comes, not together with their parent. +- Properties without a setter are skipped unless they are constructor arguments, matching what deserialization + itself does. +- It is guaranteed to run **before the first `deserialize()` call** of that object, constructor arguments + included, so a handler may treat it as the one place where it fetches - `EntityIdHandler` does exactly that + and afterwards only reads Doctrine's identity map, which is what keeps an id that does not exist from costing + a query of its own. + ### Automatically generated API documentation via NelmioApiDocBundle Model describer will be automatically registered if [NelmioApiDocBundle](https://github.com/nelmio/NelmioApiDocBundle) is present. diff --git a/src/DependencyInjection/CompilerPass/SerializerHandlerCompilerPass.php b/src/DependencyInjection/CompilerPass/SerializerHandlerCompilerPass.php index 78e2f86..09e06c4 100644 --- a/src/DependencyInjection/CompilerPass/SerializerHandlerCompilerPass.php +++ b/src/DependencyInjection/CompilerPass/SerializerHandlerCompilerPass.php @@ -6,7 +6,6 @@ use AnzuSystems\SerializerBundle\AnzuSystemsSerializerBundle; use AnzuSystems\SerializerBundle\Handler\HandlerResolver; -use AnzuSystems\SerializerBundle\Handler\Handlers\BatchHandlerInterface; use AnzuSystems\SerializerBundle\Handler\Handlers\HandlerInterface; use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; @@ -34,19 +33,11 @@ public function process(ContainerBuilder $container): void $handlerReferences[$handler] = new Reference($handler); } - $batchHandlers = []; - foreach ($handlers as $handler) { - if (is_a($handler, BatchHandlerInterface::class, true)) { - $batchHandlers[$handler] = true; - } - } - $handlerLocator = new ServiceLocatorArgument($handlerReferences); $container ->getDefinition(HandlerResolver::class) ->setArgument('$handlerLocator', $handlerLocator) ->setArgument('$handlers', $handlers) - ->setArgument('$batchHandlers', $batchHandlers) ; } } diff --git a/src/Handler/BatchItem.php b/src/Handler/BatchItem.php new file mode 100644 index 0000000..eb6a39b --- /dev/null +++ b/src/Handler/BatchItem.php @@ -0,0 +1,16 @@ +, true> $batchHandlers - */ public function __construct( private ContainerInterface $handlerLocator, private array $handlers, - private array $batchHandlers = [], ) { } - public function hasBatchHandlers(): bool - { - return [] !== $this->batchHandlers; - } - /** * @throws SerializerException */ - public function getBatchHandler(string $customHandler): ?BatchHandlerInterface + public function getHandler(string $handlerClass): HandlerInterface { - if (false === isset($this->batchHandlers[$customHandler])) { - return null; - } - try { - /** @var BatchHandlerInterface $handler */ - $handler = $this->handlerLocator->get($customHandler); + return $this->handlerLocator->get($handlerClass); } catch (NotFoundExceptionInterface|ContainerExceptionInterface $exception) { throw new SerializerException('Unable to get handler.', 0, $exception); } - - return $handler; } /** diff --git a/src/Handler/Handlers/BatchDeserializeHandlerInterface.php b/src/Handler/Handlers/BatchDeserializeHandlerInterface.php new file mode 100644 index 0000000..9172592 --- /dev/null +++ b/src/Handler/Handlers/BatchDeserializeHandlerInterface.php @@ -0,0 +1,16 @@ + $values - * * @throws SerializerException */ - public function prepareSerializeBatch(array $values, Metadata $metadata, SerializationContext $context): void; + public function prepareSerializeBatch(SerializationContext $context, BatchItem ...$items): void; } diff --git a/src/Handler/Handlers/EntityIdHandler.php b/src/Handler/Handlers/EntityIdHandler.php index 8dace1d..f8b9bb8 100644 --- a/src/Handler/Handlers/EntityIdHandler.php +++ b/src/Handler/Handlers/EntityIdHandler.php @@ -7,24 +7,43 @@ use AnzuSystems\SerializerBundle\Attributes\Serialize; use AnzuSystems\SerializerBundle\Context\SerializationContext; use AnzuSystems\SerializerBundle\Exception\SerializerException; +use AnzuSystems\SerializerBundle\Handler\BatchItem; use AnzuSystems\SerializerBundle\Helper\SerializerHelper; use AnzuSystems\SerializerBundle\Metadata\Metadata; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata as DoctrineClassMetadata; +use Doctrine\Persistence\Mapping\MappingException; use ReflectionException; use ReflectionMethod; use ReflectionNamedType; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Uid\Uuid; -final class EntityIdHandler extends AbstractHandler +final class EntityIdHandler extends AbstractHandler implements BatchDeserializeHandlerInterface { public function __construct( private readonly EntityManagerInterface $entityManager, ) { } + public function prepareDeserializeBatch(BatchItem ...$items): void + { + $idsByEntityClass = []; + foreach ($items as $item) { + $entityClass = (string) ($item->metadata->customType ?? $item->metadata->type); + $value = $item->value; + foreach (is_iterable($value) ? $value : [$value] as $id) { + $idsByEntityClass[$entityClass][] = $id; + } + } + + foreach ($idsByEntityClass as $entityClass => $ids) { + $this->preloadEntities($ids, $entityClass); + } + } + public function serialize(mixed $value, Metadata $metadata, SerializationContext $context): array|object|int|null|string { if (null === $value) { @@ -72,24 +91,7 @@ public function deserialize(mixed $value, Metadata $metadata): mixed } if (is_iterable($value)) { $entityClass = (string) $metadata->customType; - $ids = []; - foreach ($value as $id) { - $ids[] = $id; - } - $absentIds = $this->preloadEntities($ids, $entityClass); - - $entities = []; - foreach ($ids as $id) { - if ((is_int($id) || is_string($id)) && isset($absentIds[$id])) { - continue; - } - - /** @psalm-suppress ArgumentTypeCoercion */ - $entity = $this->entityManager->find($entityClass, $id); - if ($entity) { - $entities[] = $entity; - } - } + $entities = $this->loadEntities(iterator_to_array($value, preserve_keys: false), $entityClass); if (is_a($metadata->type, Collection::class, true)) { return new ArrayCollection($entities); } @@ -124,54 +126,76 @@ public function describe(string $property, Metadata $metadata): array } /** - * One query for the whole list, so the find() calls that follow hit the identity map. Ids it did not bring - * back are returned, because Doctrine has no negative cache and find() would query each of them again. + * The deserializer runs prepareDeserializeBatch() before it asks for any property, so an id the identity map + * does not know is one the database does not have - a find() fallback would only repeat the query that missed. * * @param list $ids * - * @return array + * @return list */ - private function preloadEntities(array $ids, string $entityClass): array + private function loadEntities(array $ids, string $entityClass): array { /** @psalm-suppress ArgumentTypeCoercion */ $classMetadata = $this->entityManager->getClassMetadata($entityClass); - $identifier = $classMetadata->getSingleIdentifierFieldName(); - $rootEntityName = $classMetadata->rootEntityName; - $unmanagedIds = $this->filterUnmanagedIds($ids, $identifier, $rootEntityName); - if (count($unmanagedIds) < 2) { - return []; - } + $entities = []; + foreach ($ids as $id) { + /** @psalm-suppress ArgumentTypeCoercion */ + $entity = is_int($id) || is_string($id) + ? $this->tryGetManaged($id, $classMetadata) + : $this->entityManager->find($entityClass, $id); + if (null === $entity || false === $entity instanceof $entityClass) { + continue; + } - /** @psalm-suppress ArgumentTypeCoercion */ - $this->entityManager->getRepository($entityClass) - ->findBy([$identifier => $unmanagedIds]); + $entities[] = $entity; + } - return array_flip($this->filterUnmanagedIds($unmanagedIds, $identifier, $rootEntityName)); + return $entities; } /** * @param list $ids - * - * @return list without duplicates */ - private function filterUnmanagedIds(array $ids, string $identifier, string $rootEntityName): array + private function preloadEntities(array $ids, string $entityClass): void { - $unitOfWork = $this->entityManager->getUnitOfWork(); + try { + /** @psalm-suppress ArgumentTypeCoercion */ + $classMetadata = $this->entityManager->getClassMetadata($entityClass); + } catch (MappingException) { + // The handler also serves value objects that merely carry a getId(), and warming up is never worth + // breaking a payload over. + return; + } $unmanagedIds = []; foreach ($ids as $id) { if (false === is_int($id) && false === is_string($id)) { continue; } - // The same lookup find() does, so an id it answers from the identity map is never queried again. - /** @psalm-suppress ArgumentTypeCoercion */ - if (false === $unitOfWork->tryGetById([$identifier => $id], $rootEntityName)) { + if (null === $this->tryGetManaged($id, $classMetadata)) { $unmanagedIds[$id] = $id; } } + if ([] === $unmanagedIds) { + return; + } + + /** @psalm-suppress ArgumentTypeCoercion */ + $this->entityManager->getRepository($entityClass) + ->findBy([$classMetadata->getSingleIdentifierFieldName() => $unmanagedIds]); + } + + private function tryGetManaged(int|string $id, DoctrineClassMetadata $classMetadata): ?object + { + /** @psalm-suppress ArgumentTypeCoercion */ + $entity = $this->entityManager->getUnitOfWork() + ->tryGetById( + [$classMetadata->getSingleIdentifierFieldName() => $id], + $classMetadata->rootEntityName, + ); - return array_values($unmanagedIds); + return is_object($entity) ? $entity : null; } private function getOrderedIDs(array $ids, Metadata $metadata): Collection diff --git a/src/Service/JsonDeserializer.php b/src/Service/JsonDeserializer.php index ac12225..481c97c 100644 --- a/src/Service/JsonDeserializer.php +++ b/src/Service/JsonDeserializer.php @@ -6,7 +6,9 @@ use AnzuSystems\SerializerBundle\Exception\DeserializationException; use AnzuSystems\SerializerBundle\Exception\SerializerException; +use AnzuSystems\SerializerBundle\Handler\BatchItem; use AnzuSystems\SerializerBundle\Handler\HandlerResolver; +use AnzuSystems\SerializerBundle\Handler\Handlers\BatchDeserializeHandlerInterface; use AnzuSystems\SerializerBundle\Metadata\ClassMetadata; use AnzuSystems\SerializerBundle\Metadata\Metadata; use AnzuSystems\SerializerBundle\Metadata\MetadataRegistry; @@ -49,6 +51,7 @@ public function deserialize(string $data, string $className, ?iterable $iterable public function fromArray(array $data, string $className, ?iterable $iterable = null): object|iterable { if (is_iterable($iterable)) { + $this->prepareListBatches($data, $className); if ($iterable instanceof Collection) { foreach ($data as $key => $item) { $iterable->set($key, $this->fromArray($item, $className)); @@ -78,6 +81,7 @@ public function fromArray(array $data, string $className, ?iterable $iterable = private function arrayToObject(array $data, string $className): object { $objectMetadata = $this->metadataRegistry->get($className); + $this->drainBatches($this->collectBatches($objectMetadata, $data)); $object = $this->createObjectInstance($objectMetadata, $className, $data); foreach ($objectMetadata->getAll() as $name => $metadata) { if (null === $metadata->setter || false === array_key_exists($name, $data)) { @@ -102,6 +106,69 @@ private function arrayToObject(array $data, string $className): object return $object; } + /** + * @param class-string $className + * + * @throws SerializerException + */ + private function prepareListBatches(array $data, string $className): void + { + $objectMetadata = $this->metadataRegistry->get($className); + + $batches = []; + foreach ($data as $item) { + if (false === is_array($item)) { + continue; + } + foreach ($this->collectBatches($objectMetadata, $item) as $handlerClass => $items) { + $batches[$handlerClass] = [...$batches[$handlerClass] ?? [], ...$items]; + } + } + + $this->drainBatches($batches); + } + + /** + * @return array> + */ + private function collectBatches(ClassMetadata $objectMetadata, array $data): array + { + $constructorMetadata = $objectMetadata->getConstructorMetadata(); + + $batches = []; + foreach ($objectMetadata->getAll() as $name => $metadata) { + if (false === array_key_exists($name, $data)) { + continue; + } + if (null === $metadata->setter && false === isset($constructorMetadata[$name])) { + continue; + } + $handlerClass = $metadata->customHandler; + if (null === $handlerClass || false === is_a($handlerClass, BatchDeserializeHandlerInterface::class, true)) { + continue; + } + + $batches[$handlerClass][] = new BatchItem($data[$name], $metadata); + } + + return $batches; + } + + /** + * @param array> $batches + * + * @throws SerializerException + */ + private function drainBatches(array $batches): void + { + foreach ($batches as $handlerClass => $items) { + $handler = $this->handlerResolver->getHandler($handlerClass); + if ($handler instanceof BatchDeserializeHandlerInterface) { + $handler->prepareDeserializeBatch(...$items); + } + } + } + /** * @param class-string $className * diff --git a/src/Service/JsonSerializer.php b/src/Service/JsonSerializer.php index f77700c..d4cf731 100644 --- a/src/Service/JsonSerializer.php +++ b/src/Service/JsonSerializer.php @@ -7,7 +7,9 @@ use AnzuSystems\SerializerBundle\Attributes\Serialize; use AnzuSystems\SerializerBundle\Context\SerializationContext; use AnzuSystems\SerializerBundle\Exception\SerializerException; +use AnzuSystems\SerializerBundle\Handler\BatchItem; use AnzuSystems\SerializerBundle\Handler\HandlerResolver; +use AnzuSystems\SerializerBundle\Handler\Handlers\BatchSerializeHandlerInterface; use AnzuSystems\SerializerBundle\Metadata\Metadata; use AnzuSystems\SerializerBundle\Metadata\MetadataRegistry; use Doctrine\Common\Collections\Collection; @@ -45,7 +47,7 @@ public function toArray(object|iterable $data, ?Metadata $metadata = null, ?Seri } if (is_iterable($data)) { - $this->prepareBatches($data, $context); + $preparedValues = $this->prepareBatches($data, $context); $output = []; foreach ($data as $key => $item) { if (null === $item) { @@ -56,7 +58,11 @@ public function toArray(object|iterable $data, ?Metadata $metadata = null, ?Seri continue; } - $output[$key] = is_scalar($item) ? $item : $this->toArray($item, $metadata, $context); + $output[$key] = match (true) { + is_scalar($item) => $item, + is_iterable($item) => $this->toArray($item, $metadata, $context), + default => $this->objectToArray($item, $context, $preparedValues[spl_object_id($item)] ?? []), + }; } if (Serialize::KEYS_VALUES === $metadata?->strategy) { @@ -74,13 +80,17 @@ public function toArray(object|iterable $data, ?Metadata $metadata = null, ?Seri } /** + * @param array $preparedValues + * * @throws SerializerException */ - private function objectToArray(object $data, SerializationContext $context): array + private function objectToArray(object $data, SerializationContext $context, array $preparedValues = []): array { $output = []; foreach ($this->metadataRegistry->get($data::class)->getAll() as $name => $metadata) { - $value = $this->getValue($data, $metadata); + $value = array_key_exists($name, $preparedValues) + ? $preparedValues[$name] + : $this->getValue($data, $metadata); if (null === $value && !$context->shouldSerializeNull()) { continue; @@ -96,46 +106,45 @@ private function objectToArray(object $data, SerializationContext $context): arr } /** + * @return array> the values it read, by object id and serialized name + * * @throws SerializerException */ - private function prepareBatches(iterable $data, SerializationContext $context): void + private function prepareBatches(iterable $data, SerializationContext $context): array { // A generator would be consumed by this pass, so only arrays and collections are prepared. $traversableTwice = is_array($data) || $data instanceof Collection; - if (false === $traversableTwice || false === $this->handlerResolver->hasBatchHandlers()) { - return; + if (false === $traversableTwice) { + return []; } - $handlers = []; - $metadataList = []; - $values = []; + $batches = []; + $preparedValues = []; foreach ($data as $item) { if (false === is_object($item)) { continue; } - foreach ($this->metadataRegistry->get($item::class)->getAll() as $metadata) { + foreach ($this->metadataRegistry->get($item::class)->getAll() as $name => $metadata) { $handlerClass = $metadata->customHandler; - if (null === $handlerClass) { + if (null === $handlerClass || false === is_a($handlerClass, BatchSerializeHandlerInterface::class, true)) { continue; } - $handler = $this->handlerResolver->getBatchHandler($handlerClass); - if (null === $handler) { - continue; - } - - // MetadataRegistry keeps one instance per class and property, so its identity is a stable bucket. - $bucket = spl_object_id($metadata); - $handlers[$bucket] = $handler; - $metadataList[$bucket] = $metadata; - $values[$bucket][] = $this->getValue($item, $metadata); + $value = $this->getValue($item, $metadata); + $preparedValues[spl_object_id($item)][$name] = $value; + $batches[$handlerClass][] = new BatchItem($value, $metadata); } } - foreach ($values as $bucket => $bucketValues) { - $handlers[$bucket]->prepareSerializeBatch($bucketValues, $metadataList[$bucket], $context); + foreach ($batches as $handlerClass => $items) { + $handler = $this->handlerResolver->getHandler($handlerClass); + if ($handler instanceof BatchSerializeHandlerInterface) { + $handler->prepareSerializeBatch($context, ...$items); + } } + + return $preparedValues; } private function getValue(object $data, Metadata $metadata): mixed diff --git a/tests/BatchHandlerTest.php b/tests/BatchHandlerTest.php index 8b829c7..04a8960 100644 --- a/tests/BatchHandlerTest.php +++ b/tests/BatchHandlerTest.php @@ -84,7 +84,7 @@ public function testEachSerializedCollectionGetsItsOwnBatch(): void /** * @throws SerializerException */ - public function testEachPreparedPropertyGetsItsOwnBatchWithItsMetadata(): void + public function testPropertiesOfDifferentClassesShareOneBatchAndKeepTheirMetadata(): void { $this->serializer->serialize([ new BatchDto('first', 'First'), @@ -92,8 +92,19 @@ public function testEachPreparedPropertyGetsItsOwnBatchWithItsMetadata(): void new BatchDto('second', 'Second'), ]); - self::assertSame([['first', 'second'], ['other']], $this->handler->getBatches()); - self::assertSame(['code', 'ref'], $this->handler->getBatchedProperties()); + self::assertSame([['first', 'other', 'second']], $this->handler->getBatches()); + self::assertSame([['code', 'ref', 'code']], $this->handler->getBatchedProperties()); + } + + /** + * @throws SerializerException + */ + public function testBatchedValueIsReadFromTheGetterOnlyOnce(): void + { + $item = new BatchDto('first', 'First'); + $this->serializer->serialize([$item]); + + self::assertSame(1, $item->getCodeReads()); } /** diff --git a/tests/Dto/BatchDto.php b/tests/Dto/BatchDto.php index 9751103..e7cccb2 100644 --- a/tests/Dto/BatchDto.php +++ b/tests/Dto/BatchDto.php @@ -14,6 +14,7 @@ final class BatchDto #[Serialize] private string $label; + private int $codeReads = 0; public function __construct(string $code, string $label) { @@ -23,9 +24,16 @@ public function __construct(string $code, string $label) public function getCode(): string { + $this->codeReads++; + return $this->code; } + public function getCodeReads(): int + { + return $this->codeReads; + } + public function getLabel(): string { return $this->label; diff --git a/tests/Dto/EntityIdsMultiPropDto.php b/tests/Dto/EntityIdsMultiPropDto.php new file mode 100644 index 0000000..762158b --- /dev/null +++ b/tests/Dto/EntityIdsMultiPropDto.php @@ -0,0 +1,75 @@ + + */ + #[Serialize(handler: EntityIdHandler::class, type: Example::class)] + private array $firstExamples = []; + + /** + * @var list + */ + #[Serialize(handler: EntityIdHandler::class, type: Example::class)] + private array $secondExamples = []; + + #[Serialize(handler: EntityIdHandler::class, type: Example::class)] + private ?Example $singleExample = null; + + /** + * @return list + */ + public function getFirstExamples(): array + { + return $this->firstExamples; + } + + /** + * @param list $firstExamples + */ + public function setFirstExamples(array $firstExamples): self + { + $this->firstExamples = $firstExamples; + + return $this; + } + + /** + * @return list + */ + public function getSecondExamples(): array + { + return $this->secondExamples; + } + + /** + * @param list $secondExamples + */ + public function setSecondExamples(array $secondExamples): self + { + $this->secondExamples = $secondExamples; + + return $this; + } + + public function getSingleExample(): ?Example + { + return $this->singleExample; + } + + public function setSingleExample(?Example $singleExample): self + { + $this->singleExample = $singleExample; + + return $this; + } +} diff --git a/tests/EntityIdHandlerTest.php b/tests/EntityIdHandlerTest.php index 91875b5..32ee600 100644 --- a/tests/EntityIdHandlerTest.php +++ b/tests/EntityIdHandlerTest.php @@ -6,6 +6,7 @@ use AnzuSystems\SerializerBundle\Exception\SerializerException; use AnzuSystems\SerializerBundle\Tests\Dto\EntityIdsDto; +use AnzuSystems\SerializerBundle\Tests\Dto\EntityIdsMultiPropDto; use AnzuSystems\SerializerBundle\Tests\TestApp\Entity\Example; use Doctrine\ORM\EntityManagerInterface; use Exception; @@ -108,6 +109,45 @@ public function testAlreadyLoadedIdsCostNoQuery(): void self::assertSame([self::FIRST_ID, self::SECOND_ID, self::THIRD_ID], $this->ids($dto)); } + /** + * @throws SerializerException + */ + public function testEveryPropertyOfOnePayloadSharesOneQueryPerEntityClass(): void + { + /** @var EntityIdsMultiPropDto $dto */ + $dto = $this->serializer->deserialize( + json_encode([ + 'firstExamples' => [self::FIRST_ID, self::SECOND_ID], + 'secondExamples' => [self::SECOND_ID, self::THIRD_ID], + 'singleExample' => self::FIRST_ID, + ], JSON_THROW_ON_ERROR), + EntityIdsMultiPropDto::class, + ); + + self::assertSame(1, $this->queryCount(), 'Three properties of one class must share one query'); + self::assertSame([self::FIRST_ID, self::SECOND_ID], $this->exampleIds($dto->getFirstExamples())); + self::assertSame([self::SECOND_ID, self::THIRD_ID], $this->exampleIds($dto->getSecondExamples())); + self::assertSame(self::FIRST_ID, $dto->getSingleExample()?->getId()); + } + + /** + * @throws SerializerException + */ + public function testEveryItemOfAListPayloadSharesOneQuery(): void + { + $this->serializer->deserializeIterable( + json_encode([ + ['examples' => [self::FIRST_ID]], + ['examples' => [self::SECOND_ID]], + ['examples' => [self::THIRD_ID]], + ], JSON_THROW_ON_ERROR), + EntityIdsDto::class, + [], + ); + + self::assertSame(1, $this->queryCount(), 'Three items of one list must cost one query, not one each'); + } + /** * @throws SerializerException */ @@ -145,6 +185,16 @@ private function queryCount(): int */ private function ids(EntityIdsDto $dto): array { - return array_map(static fn (Example $example): int => $example->getId(), $dto->getExamples()); + return $this->exampleIds($dto->getExamples()); + } + + /** + * @param list $examples + * + * @return list + */ + private function exampleIds(array $examples): array + { + return array_map(static fn (Example $example): int => $example->getId(), $examples); } } diff --git a/tests/TestApp/Serializer/RecordingBatchHandler.php b/tests/TestApp/Serializer/RecordingBatchHandler.php index a9e68dc..4b3c66b 100644 --- a/tests/TestApp/Serializer/RecordingBatchHandler.php +++ b/tests/TestApp/Serializer/RecordingBatchHandler.php @@ -5,11 +5,12 @@ namespace AnzuSystems\SerializerBundle\Tests\TestApp\Serializer; use AnzuSystems\SerializerBundle\Context\SerializationContext; +use AnzuSystems\SerializerBundle\Handler\BatchItem; use AnzuSystems\SerializerBundle\Handler\Handlers\AbstractHandler; -use AnzuSystems\SerializerBundle\Handler\Handlers\BatchHandlerInterface; +use AnzuSystems\SerializerBundle\Handler\Handlers\BatchSerializeHandlerInterface; use AnzuSystems\SerializerBundle\Metadata\Metadata; -final class RecordingBatchHandler extends AbstractHandler implements BatchHandlerInterface +final class RecordingBatchHandler extends AbstractHandler implements BatchSerializeHandlerInterface { /** * @var list> @@ -17,7 +18,7 @@ final class RecordingBatchHandler extends AbstractHandler implements BatchHandle private array $batches = []; /** - * @var list + * @var list> */ private array $batchedProperties = []; @@ -36,10 +37,13 @@ public function deserialize(mixed $value, Metadata $metadata): mixed return $value; } - public function prepareSerializeBatch(array $values, Metadata $metadata, SerializationContext $context): void + public function prepareSerializeBatch(SerializationContext $context, BatchItem ...$items): void { - $this->batches[] = $values; - $this->batchedProperties[] = (string) $metadata->property; + $this->batches[] = array_map(static fn (BatchItem $item): mixed => $item->value, $items); + $this->batchedProperties[] = array_map( + static fn (BatchItem $item): string => (string) $item->metadata->property, + $items, + ); $this->batchedSerializeNulls[] = $context->shouldSerializeNull(); } @@ -52,7 +56,7 @@ public function getBatches(): array } /** - * @return list + * @return list> */ public function getBatchedProperties(): array { From ea2b327e0a2d19865d77c8a4eb66ef53dc7b35e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ronald=20M=C3=A1rf=C3=B6ldi?= Date: Thu, 13 Aug 2026 15:53:02 +0000 Subject: [PATCH 6/6] Add UUID handling in EntityIdHandler and implement ExampleUuid entity with tests --- src/Handler/Handlers/EntityIdHandler.php | 26 +++++++++++++- tests/Dto/EntityUuidIdsDto.php | 36 +++++++++++++++++++ tests/EntityIdHandlerTest.php | 24 +++++++++++++ tests/TestApp/Entity/ExampleUuid.php | 45 ++++++++++++++++++++++++ tests/config/packages/doctrine.yaml | 2 ++ 5 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 tests/Dto/EntityUuidIdsDto.php create mode 100644 tests/TestApp/Entity/ExampleUuid.php diff --git a/src/Handler/Handlers/EntityIdHandler.php b/src/Handler/Handlers/EntityIdHandler.php index f8b9bb8..0f0af6b 100644 --- a/src/Handler/Handlers/EntityIdHandler.php +++ b/src/Handler/Handlers/EntityIdHandler.php @@ -12,6 +12,8 @@ use AnzuSystems\SerializerBundle\Metadata\Metadata; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\DBAL\Exception as DbalException; +use Doctrine\DBAL\Types\Type; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata as DoctrineClassMetadata; use Doctrine\Persistence\Mapping\MappingException; @@ -188,16 +190,38 @@ private function preloadEntities(array $ids, string $entityClass): void private function tryGetManaged(int|string $id, DoctrineClassMetadata $classMetadata): ?object { + $identifier = $classMetadata->getSingleIdentifierFieldName(); + /** @psalm-suppress ArgumentTypeCoercion */ $entity = $this->entityManager->getUnitOfWork() ->tryGetById( - [$classMetadata->getSingleIdentifierFieldName() => $id], + [$identifier => $this->toIdentifierValue($id, $identifier, $classMetadata)], $classMetadata->rootEntityName, ); return is_object($entity) ? $entity : null; } + /** + * Doctrine registers an entity under the identifier its type produced, so a raw json id has to go through the + * same conversion - an uppercase uuid would otherwise look like an id that does not exist, and this path has + * no find() to fall back on. + */ + private function toIdentifierValue(int|string $id, string $identifier, DoctrineClassMetadata $classMetadata): mixed + { + $typeName = $classMetadata->getTypeOfField($identifier); + if (null === $typeName) { + return $id; + } + + try { + return Type::getType($typeName) + ->convertToPHPValue($id, $this->entityManager->getConnection()->getDatabasePlatform()); + } catch (DbalException) { + return $id; + } + } + private function getOrderedIDs(array $ids, Metadata $metadata): Collection { $uuids = false; diff --git a/tests/Dto/EntityUuidIdsDto.php b/tests/Dto/EntityUuidIdsDto.php new file mode 100644 index 0000000..50a21fe --- /dev/null +++ b/tests/Dto/EntityUuidIdsDto.php @@ -0,0 +1,36 @@ + + */ + #[Serialize(handler: EntityIdHandler::class, type: ExampleUuid::class)] + private array $examples = []; + + /** + * @return list + */ + public function getExamples(): array + { + return $this->examples; + } + + /** + * @param list $examples + */ + public function setExamples(array $examples): self + { + $this->examples = $examples; + + return $this; + } +} diff --git a/tests/EntityIdHandlerTest.php b/tests/EntityIdHandlerTest.php index 32ee600..f878a9b 100644 --- a/tests/EntityIdHandlerTest.php +++ b/tests/EntityIdHandlerTest.php @@ -7,10 +7,13 @@ use AnzuSystems\SerializerBundle\Exception\SerializerException; use AnzuSystems\SerializerBundle\Tests\Dto\EntityIdsDto; use AnzuSystems\SerializerBundle\Tests\Dto\EntityIdsMultiPropDto; +use AnzuSystems\SerializerBundle\Tests\Dto\EntityUuidIdsDto; use AnzuSystems\SerializerBundle\Tests\TestApp\Entity\Example; +use AnzuSystems\SerializerBundle\Tests\TestApp\Entity\ExampleUuid; use Doctrine\ORM\EntityManagerInterface; use Exception; use Symfony\Bridge\Doctrine\Middleware\Debug\DebugDataHolder; +use Symfony\Component\Uid\Uuid; final class EntityIdHandlerTest extends AbstractTestCase { @@ -47,6 +50,7 @@ protected function setUp(): void protected function tearDown(): void { $this->entityManager->createQuery('DELETE FROM ' . Example::class)->execute(); + $this->entityManager->createQuery('DELETE FROM ' . ExampleUuid::class)->execute(); $this->entityManager->clear(); parent::tearDown(); @@ -148,6 +152,26 @@ public function testEveryItemOfAListPayloadSharesOneQuery(): void self::assertSame(1, $this->queryCount(), 'Three items of one list must cost one query, not one each'); } + /** + * @throws SerializerException + */ + public function testUuidIdIsResolvedWhateverCaseItArrivesIn(): void + { + $uuid = Uuid::v4(); + $this->entityManager->persist(new ExampleUuid()->setId($uuid)->setName('uuid-example')); + $this->entityManager->flush(); + $this->entityManager->clear(); + + /** @var EntityUuidIdsDto $dto */ + $dto = $this->serializer->deserialize( + json_encode(['examples' => [strtoupper($uuid->toRfc4122())]], JSON_THROW_ON_ERROR), + EntityUuidIdsDto::class, + ); + + self::assertCount(1, $dto->getExamples()); + self::assertTrue($uuid->equals($dto->getExamples()[0]->getId())); + } + /** * @throws SerializerException */ diff --git a/tests/TestApp/Entity/ExampleUuid.php b/tests/TestApp/Entity/ExampleUuid.php new file mode 100644 index 0000000..d3ccd76 --- /dev/null +++ b/tests/TestApp/Entity/ExampleUuid.php @@ -0,0 +1,45 @@ +id; + } + + public function setId(Uuid $id): self + { + $this->id = $id; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/tests/config/packages/doctrine.yaml b/tests/config/packages/doctrine.yaml index c9c245d..8e52d39 100644 --- a/tests/config/packages/doctrine.yaml +++ b/tests/config/packages/doctrine.yaml @@ -3,6 +3,8 @@ doctrine: url: '%env(resolve:DB_BUNDLE_URL)%' profiling: true profiling_collect_backtrace: false + types: + uuid: Symfony\Bridge\Doctrine\Types\UuidType orm: auto_generate_proxy_classes: true enable_native_lazy_objects: true