diff --git a/README.md b/README.md index 6a3cf96..617472f 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,103 @@ 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 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, 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\BatchSerializeHandlerInterface; +use AnzuSystems\SerializerBundle\Metadata\Metadata; + +final class AuthorHandler extends AbstractHandler implements BatchSerializeHandlerInterface +{ + /** @var array */ + private array $authors = []; + + public function prepareSerializeBatch(SerializationContext $context, BatchItem ...$items): void + { + $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; + } + } + + /** + * @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**, 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: + 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. + +#### 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/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/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 @@ +handlerLocator->get($handlerClass); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface $exception) { + throw new SerializerException('Unable to get handler.', 0, $exception); + } + } + /** * @throws SerializerException */ 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 @@ +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) { @@ -71,14 +92,8 @@ public function deserialize(mixed $value, Metadata $metadata): mixed return null; } if (is_iterable($value)) { - $entities = []; - foreach ($value as $id) { - /** @psalm-suppress ArgumentTypeCoercion */ - $entity = $this->entityManager->find((string) $metadata->customType, $id); - if ($entity) { - $entities[] = $entity; - } - } + $entityClass = (string) $metadata->customType; + $entities = $this->loadEntities(iterator_to_array($value, preserve_keys: false), $entityClass); if (is_a($metadata->type, Collection::class, true)) { return new ArrayCollection($entities); } @@ -112,6 +127,101 @@ public function describe(string $property, Metadata $metadata): array return $description; } + /** + * 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 list + */ + private function loadEntities(array $ids, string $entityClass): array + { + /** @psalm-suppress ArgumentTypeCoercion */ + $classMetadata = $this->entityManager->getClassMetadata($entityClass); + + $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; + } + + $entities[] = $entity; + } + + return $entities; + } + + /** + * @param list $ids + */ + private function preloadEntities(array $ids, string $entityClass): void + { + 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; + } + 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 + { + $identifier = $classMetadata->getSingleIdentifierFieldName(); + + /** @psalm-suppress ArgumentTypeCoercion */ + $entity = $this->entityManager->getUnitOfWork() + ->tryGetById( + [$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; @@ -125,7 +235,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)') @@ -143,7 +254,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 []; } 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 bc85a0a..d4cf731 100644 --- a/src/Service/JsonSerializer.php +++ b/src/Service/JsonSerializer.php @@ -7,9 +7,12 @@ 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; use JsonException; final class JsonSerializer @@ -44,6 +47,7 @@ public function toArray(object|iterable $data, ?Metadata $metadata = null, ?Seri } if (is_iterable($data)) { + $preparedValues = $this->prepareBatches($data, $context); $output = []; foreach ($data as $key => $item) { if (null === $item) { @@ -54,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) { @@ -72,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 = $metadata->getterSetterStrategy ? $data->{$metadata->getter}() : $data->{$metadata->property}; + $value = array_key_exists($name, $preparedValues) + ? $preparedValues[$name] + : $this->getValue($data, $metadata); if (null === $value && !$context->shouldSerializeNull()) { continue; @@ -92,4 +104,51 @@ private function objectToArray(object $data, SerializationContext $context): arr return $output; } + + /** + * @return array> the values it read, by object id and serialized name + * + * @throws SerializerException + */ + 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) { + return []; + } + + $batches = []; + $preparedValues = []; + foreach ($data as $item) { + if (false === is_object($item)) { + continue; + } + + foreach ($this->metadataRegistry->get($item::class)->getAll() as $name => $metadata) { + $handlerClass = $metadata->customHandler; + if (null === $handlerClass || false === is_a($handlerClass, BatchSerializeHandlerInterface::class, true)) { + continue; + } + + $value = $this->getValue($item, $metadata); + $preparedValues[spl_object_id($item)][$name] = $value; + $batches[$handlerClass][] = new BatchItem($value, $metadata); + } + } + + 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 + { + 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..04a8960 --- /dev/null +++ b/tests/BatchHandlerTest.php @@ -0,0 +1,139 @@ +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()); + } + + /** + * @throws SerializerException + */ + public function testPropertiesOfDifferentClassesShareOneBatchAndKeepTheirMetadata(): void + { + $this->serializer->serialize([ + new BatchDto('first', 'First'), + new BatchOtherDto('other'), + new BatchDto('second', 'Second'), + ]); + + 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()); + } + + /** + * @throws SerializerException + */ + public function testBatchReceivesTheSerializationContext(): void + { + $this->serializer->serialize(self::items(), SerializationContext::create()->setSerializeNulls(false)); + + self::assertSame([false], $this->handler->getBatchedSerializeNulls()); + } + + /** + * @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..e7cccb2 --- /dev/null +++ b/tests/Dto/BatchDto.php @@ -0,0 +1,41 @@ +code = $code; + $this->label = $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/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/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/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/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/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 new file mode 100644 index 0000000..f878a9b --- /dev/null +++ b/tests/EntityIdHandlerTest.php @@ -0,0 +1,224 @@ +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->createQuery('DELETE FROM ' . ExampleUuid::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 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 + */ + 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 + */ + 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 + */ + 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 $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/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/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/TestApp/Serializer/RecordingBatchHandler.php b/tests/TestApp/Serializer/RecordingBatchHandler.php new file mode 100644 index 0000000..4b3c66b --- /dev/null +++ b/tests/TestApp/Serializer/RecordingBatchHandler.php @@ -0,0 +1,73 @@ +> + */ + private array $batches = []; + + /** + * @var list> + */ + private array $batchedProperties = []; + + /** + * @var list + */ + private array $batchedSerializeNulls = []; + + 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(SerializationContext $context, BatchItem ...$items): void + { + $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(); + } + + /** + * @return list> + */ + public function getBatches(): array + { + return $this->batches; + } + + /** + * @return list> + */ + public function getBatchedProperties(): array + { + return $this->batchedProperties; + } + + /** + * @return list + */ + public function getBatchedSerializeNulls(): array + { + return $this->batchedSerializeNulls; + } +} diff --git a/tests/config/packages/doctrine.yaml b/tests/config/packages/doctrine.yaml index 73ed907..8e52d39 100644 --- a/tests/config/packages/doctrine.yaml +++ b/tests/config/packages/doctrine.yaml @@ -1,6 +1,10 @@ doctrine: dbal: 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 @@ -9,5 +13,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() + ; };