diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 250830a..b8e0138 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -45,6 +45,6 @@ jobs: - name: Install dependencies run: composer update --no-interaction --prefer-dist - name: PHPStan - run: bin/phpstan analyse --no-progress + run: bin/phpstan analyse --no-progress --memory-limit=512M - name: Code style run: bin/php-cs-fixer check --diff diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ade13..0dd682c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,15 @@ See [UPGRADE-2.0.md](UPGRADE-2.0.md) for upgrade instructions. - When `framework.enabled_locales` is configured, requested locales outside the list (query parameter or `Accept-Language`) fall back to the default locale (#71) - README rewritten with PHP attributes and current API Platform metadata (#77) +- **BREAKING** Translatable resources exposing `PUT` must set + `extraProperties: ['standard_put' => false]` on the operation so nested + translations are reconciled against the managed entity instead of a fresh + object (#81) ### Added +- In-place, merge-by-locale denormalization of nested translation writes: each + submitted locale updates its existing translation row (stable id), `PATCH` + keeps locales absent from the payload and `PUT` removes them (#81) - PHPStan level 6 and php-cs-fixer, enforced in CI (#78) - CI matrix covering PHP 8.2 to 8.5 plus a lowest-dependencies leg (#76) diff --git a/README.md b/README.md index abba7f4..6c4a10d 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Example: use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use Doctrine\Common\Collections\Collection; @@ -58,7 +59,13 @@ use Symfony\Component\Serializer\Attribute\Groups; new Get(), new GetCollection(), new Post(normalizationContext: ['groups' => ['translations']]), - new Put(normalizationContext: ['groups' => ['translations']]), + new Patch(normalizationContext: ['groups' => ['translations']]), + // PUT replaces the resource; standard_put must be off so it edits the + // managed entity instead of building a new one (see the notes below). + new Put( + normalizationContext: ['groups' => ['translations']], + extraProperties: ['standard_put' => false], + ), ], normalizationContext: ['groups' => ['article_read']], denormalizationContext: ['groups' => ['article_write']], @@ -158,6 +165,22 @@ class ArticleTranslation extends AbstractTranslation - The `translation.groups` filter (registered by this bundle) lets clients request all translation objects in a response via `?groups[]=translations`. Without the `translations` group, responses contain only the requested (or fallback) locale. - Add the `translations` group to the `normalizationContext` of `POST` and `PUT`/`PATCH` operations, as in the example above, so write operations return all translation objects. +**Editing translations (`PUT` vs `PATCH`):** the bundle populates the submitted translations onto the managed entity, keeping existing translation rows (and their ids) stable, following the HTTP semantics of each method: +- `PATCH` (`application/merge-patch+json`) is a partial edit: it updates the submitted locales and leaves the others untouched. This is the recommended way to edit translations and needs no extra configuration. +- `PUT` is a full replace: locales absent from the payload are removed. + +For `PUT` you **must** disable API Platform's `standard_put`, either per operation (`extraProperties: ['standard_put' => false]`, as above) or once for the whole API: + +``` yaml +# config/packages/api_platform.yaml +api_platform: + defaults: + extra_properties: + standard_put: false +``` + +With `standard_put` on, API Platform deserializes into a brand-new object and copies its properties (including the `translations` collection) over the managed entity, so translations cannot be matched to their existing rows. The bundle detects this misconfiguration and fails with an explicit error instead of letting the write die in the persistence layer. + Usage: ------ diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index cb49755..513c6ba 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -20,3 +20,16 @@ the list now fall back to the default locale instead of being accepted verbatim (both the `?locale=` query parameter and the `Accept-Language` header). Without `enabled_locales`, behavior is unchanged. + +5. Nested translation writes are now reconciled in place. When a write payload + carries the `translations` map, each submitted locale updates its existing + translation row (keeping the id) instead of the collection being recreated: + `PATCH` leaves locales absent from the payload untouched (partial edit) and + `PUT` removes them (full replace). The map key is the authoritative locale; a + `locale` in the body no longer relabels the addressed row. Expose `PUT` on + translatable resources with `extraProperties: ['standard_put' => false]` (or + set `api_platform.defaults.extra_properties.standard_put: false` globally) so + API Platform edits the managed entity; with `standard_put` on, its persist + processor copies the fresh translations collection over the managed one and + the rows cannot be matched, so the bundle rejects such a `PUT` with a + `LogicException`. `PATCH` needs no extra configuration. diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0299719..58e0e9c 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -15,3 +15,10 @@ parameters: identifier: argument.type path: tests/Model/TranslatableTraitTest.php count: 1 + # The owning-side `$translatable` is typed against the interface (property + # invariance forbids narrowing it to the concrete entity in the fixture), + # so phpstan-doctrine sees the association target as wider than the schema. + - + identifier: doctrine.associationType + path: tests/Fixtures/Orm/IntegrationArticleTranslation.php + count: 1 diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml index cedb0ad..d5d4bef 100644 --- a/src/Resources/config/services.yml +++ b/src/Resources/config/services.yml @@ -16,6 +16,14 @@ services: - { name: doctrine.event_listener, event: postLoad } - { name: doctrine.event_listener, event: prePersist } +# Serializer: in-place, merge denormalization of nested translations + locastic_api_platform_translation.serializer.translatable_denormalizer: + class: Locastic\ApiPlatformTranslationBundle\Serializer\TranslatableItemDenormalizer + tags: + - { name: serializer.normalizer, priority: 100 } + + Locastic\ApiPlatformTranslationBundle\Serializer\TranslatableItemDenormalizer: '@locastic_api_platform_translation.serializer.translatable_denormalizer' + # Filters locastic_api_platform_translation.filter.translation_groups: parent: 'api_platform.serializer.group_filter' diff --git a/src/Serializer/TranslatableItemDenormalizer.php b/src/Serializer/TranslatableItemDenormalizer.php new file mode 100644 index 0000000..5538875 --- /dev/null +++ b/src/Serializer/TranslatableItemDenormalizer.php @@ -0,0 +1,224 @@ + false]` (or only offer PATCH). Otherwise API + * Platform's standard_put builds a fresh object with no existing translations to + * reconcile against, and its persist processor then copies the fresh collection over + * the managed one, so the relation cannot be persisted. A misconfigured PUT fails + * loudly here (see assertNotStandardPut()) instead of dying later in the persist layer. + */ +final class TranslatableItemDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface +{ + use DenormalizerAwareTrait; + + private const ALREADY_CALLED = 'LOCASTIC_TRANSLATABLE_DENORMALIZER_CALLED'; + + /** + * @param array $context + */ + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return \is_array($data) + && isset($data['translations']) + && \is_array($data['translations']) + && is_a($type, TranslatableInterface::class, true) + && empty($context[self::ALREADY_CALLED]); + } + + public function getSupportedTypes(?string $format): array + { + // Only ever supports object denormalization, and the decision depends on the + // payload (must contain `translations`), so it is never cacheable. + return ['object' => false]; + } + + /** + * @param array $context + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + $this->assertNotStandardPut($type, $context); + + $translationsData = $data['translations']; + unset($data['translations']); + + // Let API Platform build/populate the translatable itself (minus translations). + $context[self::ALREADY_CALLED] = true; + $translatable = $this->denormalizer->denormalize($data, $type, $format, $context); + + if (!$translatable instanceof TranslatableInterface) { + return $translatable; + } + + $submittedLocales = []; + + foreach ($translationsData as $key => $translationData) { + if (!\is_array($translationData)) { + continue; + } + + // The map key is the authoritative locale; only fall back to the body + // for list-shaped payloads (numeric keys). + $locale = \is_string($key) ? $key : ($translationData['locale'] ?? null); + if (null === $locale) { + continue; + } + + // A `locale` in the body must not override the resolved locale. + unset($translationData['locale']); + + $submittedLocales[$locale] = true; + + $target = $this->matchTranslation($translatable, $locale); + $isNew = null === $target; + if ($isNew) { + $target = $this->createTranslationFor($translatable); + if (null === $target) { + continue; + } + } + $target->setLocale($locale); + + $nestedContext = $context; + $nestedContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $target; + unset($nestedContext[self::ALREADY_CALLED]); + + $translation = $this->denormalizer->denormalize( + $translationData, + $target::class, + $format, + $nestedContext, + ); + + if ($isNew && $translation instanceof TranslationInterface) { + $translatable->addTranslation($translation); + } + } + + // PUT replaces the whole resource: drop locales absent from the payload. + // PATCH is a partial edit and leaves them in place. + if ($this->isFullReplace($context)) { + foreach ($translatable->getTranslations()->toArray() as $translation) { + if (!isset($submittedLocales[(string) $translation->getLocale()])) { + $translatable->removeTranslation($translation); + } + } + } + + return $translatable; + } + + /** + * Matches an existing translation by locale. + * + * Iterates the collection instead of using `get($locale)` so it does not depend on + * the association being mapped with `indexBy: 'locale'`; a plain OneToMany hydrates + * as a 0-indexed list. This mirrors how the model trait resolves translations. + * + * @param TranslatableInterface $translatable + */ + private function matchTranslation(TranslatableInterface $translatable, string $locale): ?TranslationInterface + { + foreach ($translatable->getTranslations() as $translation) { + if ($translation->getLocale() === $locale) { + return $translation; + } + } + + return null; + } + + /** + * Instantiates a new translation via the translatable's own factory + * (TranslatableTrait::createTranslation()), without widening its visibility. + * + * @param TranslatableInterface $translatable + */ + private function createTranslationFor(TranslatableInterface $translatable): ?TranslationInterface + { + if (!method_exists($translatable, 'createTranslation')) { + return null; + } + + $translation = (new \ReflectionMethod($translatable, 'createTranslation'))->invoke($translatable); + + return $translation instanceof TranslationInterface ? $translation : null; + } + + /** + * @param array $context + */ + private function isFullReplace(array $context): bool + { + $operation = $this->findOperation($context); + + return null !== $operation && 'PUT' === strtoupper((string) $operation->getMethod()); + } + + /** + * Fails loudly on a misconfigured PUT instead of letting the write die later in + * the persist layer with an obscure Doctrine error. + * + * With standard_put enabled, API Platform deserializes into a fresh object (no + * OBJECT_TO_POPULATE) and its Doctrine PersistProcessor then reflection-copies + * every property, including the translations collection, from that fresh object + * onto the managed entity. Translations therefore cannot be reconciled by locale + * at any layer; the operation must disable standard_put. + * + * @param array $context + */ + private function assertNotStandardPut(string $type, array $context): void + { + if (isset($context[AbstractNormalizer::OBJECT_TO_POPULATE]) || !$this->isFullReplace($context)) { + return; + } + + $operation = $this->findOperation($context); + if (null === $operation || !method_exists($operation, 'getExtraProperties')) { + return; + } + + $extraProperties = $operation->getExtraProperties(); + if (\is_array($extraProperties) && false !== ($extraProperties['standard_put'] ?? true)) { + throw new \LogicException(sprintf('PUT operations on translatable resources require standard_put to be disabled, otherwise translations cannot be matched to their existing rows. Set extraProperties: [\'standard_put\' => false] on the PUT operation of "%s" (or globally via api_platform.defaults.extra_properties.standard_put: false), or expose PATCH instead.', $type)); + } + } + + /** + * @param array $context + */ + private function findOperation(array $context): ?object + { + foreach (['operation', 'root_operation'] as $key) { + $operation = $context[$key] ?? null; + if (\is_object($operation) && method_exists($operation, 'getMethod')) { + return $operation; + } + } + + return null; + } +} diff --git a/tests/Fixtures/Orm/IntegrationArticle.php b/tests/Fixtures/Orm/IntegrationArticle.php new file mode 100644 index 0000000..283e7ae --- /dev/null +++ b/tests/Fixtures/Orm/IntegrationArticle.php @@ -0,0 +1,50 @@ + + */ +#[ORM\Entity] +class IntegrationArticle extends AbstractTranslatable +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + /** + * @var Collection + */ + #[ORM\OneToMany( + targetEntity: IntegrationArticleTranslation::class, + mappedBy: 'translatable', + cascade: ['persist'], + orphanRemoval: true, + )] + protected Collection $translations; + + public function getId(): ?int + { + return $this->id; + } + + protected function createTranslation(): TranslationInterface + { + return new IntegrationArticleTranslation(); + } +} diff --git a/tests/Fixtures/Orm/IntegrationArticleTranslation.php b/tests/Fixtures/Orm/IntegrationArticleTranslation.php new file mode 100644 index 0000000..ed5f9c4 --- /dev/null +++ b/tests/Fixtures/Orm/IntegrationArticleTranslation.php @@ -0,0 +1,45 @@ +id; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(?string $title): void + { + $this->title = $title; + } +} diff --git a/tests/Serializer/TranslatableItemDenormalizerIntegrationTest.php b/tests/Serializer/TranslatableItemDenormalizerIntegrationTest.php new file mode 100644 index 0000000..1361eef --- /dev/null +++ b/tests/Serializer/TranslatableItemDenormalizerIntegrationTest.php @@ -0,0 +1,224 @@ += 80400) { + $config->enableNativeLazyObjects(true); + } else { + $config->setProxyDir(sys_get_temp_dir()); + $config->setProxyNamespace('LocasticApiPlatformTranslationTestProxies'); + } + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $config); + $this->em = new EntityManager($connection, $config); + + $schemaTool = new SchemaTool($this->em); + $schemaTool->createSchema($this->em->getMetadataFactory()->getAllMetadata()); + + // Real serializer chain: the SUT (priority first), then the generic object + // denormalizer that builds/populates the translatable and each translation. + // The Serializer wires itself into the DenormalizerAware SUT automatically. + $this->serializer = new Serializer([ + new TranslatableItemDenormalizer(), + new ArrayDenormalizer(), + new ObjectNormalizer(), + ]); + } + + public function testPatchMergesInPlaceKeepingIdsAndAbsentLocales(): void + { + $id = $this->seedArticle(['en' => 'EN', 'de' => 'DE']); + [$enId, $deId] = [$this->translationId($id, 'en'), $this->translationId($id, 'de')]; + + $article = $this->em->find(IntegrationArticle::class, $id); + $this->serializer->denormalize( + ['translations' => ['en' => ['title' => 'EN-edit']]], + IntegrationArticle::class, + 'json', + [ + AbstractNormalizer::OBJECT_TO_POPULATE => $article, + 'operation' => new Patch(), + ], + ); + $this->em->flush(); + $this->em->clear(); + + $rows = $this->translations($id); + self::assertSame(['de', 'en'], array_keys($rows), 'both locales survive a PATCH'); + self::assertSame('EN-edit', $rows['en']->getTitle(), 'submitted locale is updated'); + self::assertSame('DE', $rows['de']->getTitle(), 'absent locale is left untouched'); + self::assertSame($enId, $rows['en']->getId(), 'en row updated in place, id stable'); + self::assertSame($deId, $rows['de']->getId(), 'de row untouched, id stable'); + } + + public function testPatchCreatesNewLocaleLeavingExistingAlone(): void + { + $id = $this->seedArticle(['en' => 'EN']); + $enId = $this->translationId($id, 'en'); + + $article = $this->em->find(IntegrationArticle::class, $id); + $this->serializer->denormalize( + ['translations' => ['de' => ['title' => 'DE-new']]], + IntegrationArticle::class, + 'json', + [ + AbstractNormalizer::OBJECT_TO_POPULATE => $article, + 'operation' => new Patch(), + ], + ); + $this->em->flush(); + $this->em->clear(); + + $rows = $this->translations($id); + self::assertSame(['de', 'en'], array_keys($rows)); + self::assertSame('DE-new', $rows['de']->getTitle()); + self::assertSame($enId, $rows['en']->getId(), 'existing en row is not recreated'); + } + + public function testPutRemovesAbsentLocalesFromDatabase(): void + { + $id = $this->seedArticle(['en' => 'EN', 'de' => 'DE']); + $enId = $this->translationId($id, 'en'); + + $article = $this->em->find(IntegrationArticle::class, $id); + $this->serializer->denormalize( + ['translations' => ['en' => ['title' => 'EN-put']]], + IntegrationArticle::class, + 'json', + [ + AbstractNormalizer::OBJECT_TO_POPULATE => $article, + 'operation' => new Put(), + ], + ); + $this->em->flush(); + $this->em->clear(); + + $rows = $this->translations($id); + self::assertSame(['en'], array_keys($rows), 'PUT drops locales absent from the payload'); + self::assertSame('EN-put', $rows['en']->getTitle()); + self::assertSame($enId, $rows['en']->getId(), 'kept locale is still updated in place'); + self::assertSame(1, $this->countTranslationRows(), 'de row is deleted via orphanRemoval'); + } + + public function testMapKeyWinsOverBodyLocale(): void + { + $id = $this->seedArticle(['en' => 'EN', 'de' => 'DE']); + $enId = $this->translationId($id, 'en'); + + // A conflicting `locale` in the body must not relabel the row addressed by the map key. + $article = $this->em->find(IntegrationArticle::class, $id); + $this->serializer->denormalize( + ['translations' => ['en' => ['locale' => 'de', 'title' => 'EN-edit']]], + IntegrationArticle::class, + 'json', + [ + AbstractNormalizer::OBJECT_TO_POPULATE => $article, + 'operation' => new Patch(), + ], + ); + $this->em->flush(); + $this->em->clear(); + + $rows = $this->translations($id); + self::assertSame(['de', 'en'], array_keys($rows), 'no locale was relabeled or duplicated'); + self::assertSame($enId, $rows['en']->getId(), 'the en row is edited in place'); + self::assertSame('EN-edit', $rows['en']->getTitle()); + self::assertSame('en', $rows['en']->getLocale(), 'body locale is ignored; map key is authoritative'); + self::assertSame('DE', $rows['de']->getTitle(), 'de row is untouched'); + } + + /** + * @param array $localeToTitle + * + * @return int the persisted article id + */ + private function seedArticle(array $localeToTitle): int + { + $article = new IntegrationArticle(); + foreach ($localeToTitle as $locale => $title) { + $translation = new IntegrationArticleTranslation(); + $translation->setLocale($locale); + $translation->setTitle($title); + $article->addTranslation($translation); + } + + $this->em->persist($article); + $this->em->flush(); + $this->em->clear(); + + return $article->getId(); + } + + /** + * @return array translations keyed and sorted by locale + */ + private function translations(int $articleId): array + { + $article = $this->em->find(IntegrationArticle::class, $articleId); + $rows = []; + foreach ($article->getTranslations() as $translation) { + $rows[(string) $translation->getLocale()] = $translation; + } + ksort($rows); + + return $rows; + } + + private function translationId(int $articleId, string $locale): int + { + return (int) $this->translations($articleId)[$locale]->getId(); + } + + private function countTranslationRows(): int + { + return (int) $this->em->getConnection() + ->executeQuery('SELECT COUNT(*) FROM '.$this->translationTable()) + ->fetchOne(); + } + + private function translationTable(): string + { + return $this->em->getClassMetadata(IntegrationArticleTranslation::class)->getTableName(); + } +} diff --git a/tests/Serializer/TranslatableItemDenormalizerTest.php b/tests/Serializer/TranslatableItemDenormalizerTest.php new file mode 100644 index 0000000..ea5104b --- /dev/null +++ b/tests/Serializer/TranslatableItemDenormalizerTest.php @@ -0,0 +1,211 @@ +denormalizer = new TranslatableItemDenormalizer(); + } + + public function testSupportsOnlyTranslatablePayloadsCarryingTranslations(): void + { + self::assertTrue($this->denormalizer->supportsDenormalization(['translations' => []], DummyTranslatable::class)); + self::assertFalse($this->denormalizer->supportsDenormalization(['translations' => []], DummyNotTranslatable::class)); + self::assertFalse($this->denormalizer->supportsDenormalization(['title' => 'x'], DummyTranslatable::class)); + // Re-entrant guard: the delegated parent call must not be intercepted again. + self::assertFalse($this->denormalizer->supportsDenormalization( + ['translations' => []], + DummyTranslatable::class, + null, + ['LOCASTIC_TRANSLATABLE_DENORMALIZER_CALLED' => true], + )); + } + + public function testPatchPopulatesExistingTranslationInPlaceAndKeepsAbsentLocales(): void + { + $translatable = $this->translatableWith(['en' => 'EN', 'de' => 'DE']); + $en = $translatable->getTranslations()->get('en'); + $de = $translatable->getTranslations()->get('de'); + $this->denormalizer->setDenormalizer($this->innerReturning($translatable)); + + $result = $this->denormalizer->denormalize( + ['translations' => ['en' => ['translation' => 'EN-edit']]], + DummyTranslatable::class, + null, + ['operation' => $this->operation('PATCH')], + ); + + self::assertSame($translatable, $result); + self::assertSame(['en', 'de'], array_keys($result->getTranslations()->toArray())); + self::assertSame($en, $result->getTranslations()->get('en'), 'existing translation updated in place'); + self::assertSame('EN-edit', $en->getTranslation()); + self::assertSame($de, $result->getTranslations()->get('de'), 'absent locale left untouched'); + } + + public function testPatchCreatesTranslationForNewLocale(): void + { + $translatable = $this->translatableWith(['en' => 'EN']); + $this->denormalizer->setDenormalizer($this->innerReturning($translatable)); + + $result = $this->denormalizer->denormalize( + ['translations' => ['de' => ['translation' => 'DE-new']]], + DummyTranslatable::class, + null, + ['operation' => $this->operation('PATCH')], + ); + + self::assertEqualsCanonicalizing(['en', 'de'], array_keys($result->getTranslations()->toArray())); + self::assertSame('de', $result->getTranslations()->get('de')->getLocale()); + self::assertSame('DE-new', $result->getTranslations()->get('de')->getTranslation()); + } + + public function testPutRemovesLocalesAbsentFromPayload(): void + { + $translatable = $this->translatableWith(['en' => 'EN', 'de' => 'DE']); + $this->denormalizer->setDenormalizer($this->innerReturning($translatable)); + + $result = $this->denormalizer->denormalize( + ['translations' => ['en' => ['translation' => 'EN-put']]], + DummyTranslatable::class, + null, + ['operation' => $this->operation('PUT')], + ); + + self::assertSame(['en'], array_keys($result->getTranslations()->toArray())); + self::assertSame('EN-put', $result->getTranslations()->get('en')->getTranslation()); + } + + public function testPutReplacesCollectionToMatchPayload(): void + { + $translatable = $this->translatableWith(['en' => 'EN', 'de' => 'DE']); + $this->denormalizer->setDenormalizer($this->innerReturning($translatable)); + + $result = $this->denormalizer->denormalize( + ['translations' => ['en' => ['translation' => 'EN'], 'hr' => ['translation' => 'HR']]], + DummyTranslatable::class, + null, + ['operation' => $this->operation('PUT')], + ); + + self::assertEqualsCanonicalizing(['en', 'hr'], array_keys($result->getTranslations()->toArray())); + } + + public function testStandardPutWithoutObjectToPopulateFailsLoudly(): void + { + $this->denormalizer->setDenormalizer($this->innerReturning($this->translatableWith([]))); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('standard_put'); + + // Real Put metadata without extraProperties: standard_put defaults to on, and + // API Platform then deserializes into a fresh object (no OBJECT_TO_POPULATE). + $this->denormalizer->denormalize( + ['translations' => ['en' => ['translation' => 'EN']]], + DummyTranslatable::class, + null, + ['operation' => new Put()], + ); + } + + public function testPutWithStandardPutDisabledIsAccepted(): void + { + $translatable = $this->translatableWith(['en' => 'EN', 'de' => 'DE']); + $this->denormalizer->setDenormalizer($this->innerReturning($translatable)); + + $result = $this->denormalizer->denormalize( + ['translations' => ['en' => ['translation' => 'EN-put']]], + DummyTranslatable::class, + null, + ['operation' => new Put(extraProperties: ['standard_put' => false])], + ); + + self::assertSame(['en'], array_keys($result->getTranslations()->toArray())); + } + + public function testResolvesLocaleFromItemWhenTranslationsAreAList(): void + { + $translatable = $this->translatableWith([]); + $this->denormalizer->setDenormalizer($this->innerReturning($translatable)); + + $result = $this->denormalizer->denormalize( + ['translations' => [ + ['locale' => 'en', 'translation' => 'EN'], + ['locale' => 'de', 'translation' => 'DE'], + ]], + DummyTranslatable::class, + ); + + self::assertEqualsCanonicalizing(['en', 'de'], array_keys($result->getTranslations()->toArray())); + } + + /** + * @param array $localeToText + */ + private function translatableWith(array $localeToText): DummyTranslatable + { + $translatable = new DummyTranslatable(); + foreach ($localeToText as $locale => $text) { + $translation = new DummyTranslation(); + $translation->setLocale($locale); + $translation->setTranslation($text); + $translatable->addTranslation($translation); + } + + return $translatable; + } + + /** + * Inner denormalizer double: returns the prepared translatable for the parent call, + * and populates the object_to_populate for each nested translation call. + */ + private function innerReturning(DummyTranslatable $translatable): DenormalizerInterface + { + $inner = $this->createMock(DenormalizerInterface::class); + $inner->method('denormalize')->willReturnCallback( + static function (mixed $data, string $type, ?string $format = null, array $context = []) use ($translatable): object { + if (is_a($type, TranslatableInterface::class, true)) { + return $translatable; + } + + $target = $context[AbstractNormalizer::OBJECT_TO_POPULATE]; + if ($target instanceof DummyTranslation && \is_array($data) && isset($data['translation'])) { + $target->setTranslation($data['translation']); + } + + return $target; + } + ); + + return $inner; + } + + private function operation(string $method): object + { + return new class($method) { + public function __construct(private string $method) + { + } + + public function getMethod(): string + { + return $this->method; + } + }; + } +}