diff --git a/CHANGELOG.md b/CHANGELOG.md index d79a157..6dd2075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `locale_resolution` (ordered locale sources, `query_param` and `accept_language`; each can be removed or reordered) (#85) - FQCN alias for the `Translator` service, so it can be autowired by type (#85) +- `auto_create_translations` option: set to `false` to stop `getTranslation()` + from creating and attaching a translation for a missing locale (which, with + `cascade: persist`, lets a read insert empty rows); it returns a detached, + never-persisted translation instead (#55, #34) +- `getOrCreateTranslation()` on `TranslatableTrait`: the explicit write target + for virtual setters; creates and attaches the missing translation for the + exact locale, without locale fallback, regardless of + `auto_create_translations` ### Changed - Modern bundle layout: the bundle class extends `AbstractBundle` and services @@ -28,6 +36,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Deprecated - `ApiPlatformTranslationExtension`, to be removed in 3.0; the bundle registers its extension itself through `AbstractBundle` (#85) +- Not setting `auto_create_translations` explicitly; its default (`true`) + flips to `false` in 3.0 (see UPGRADE-2.1.md) ## [2.0.1] - 2026-07-07 diff --git a/README.md b/README.md index f89c8e7..e8fd465 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,15 @@ api_platform_translation: # null (the default) inherits framework.default_locale. fallback_locale: null + # true (the legacy behavior): reading a translation that does not exist + # creates an empty one and attaches it to the entity, and with the + # documented cascade persist mapping a plain read can then insert empty + # rows into the database. false: reads never write, a missing translation + # just shows empty fields. Setters work either way, they write through + # getOrCreateTranslation() (see below). The default flips to false in + # 3.0; leaving the option unset is deprecated since 2.1. + auto_create_translations: true + # Ordered sources the request locale is resolved from; the first source # producing a locale wins. Remove a source to disable it. locale_resolution: @@ -68,7 +77,7 @@ Implementation: - Extend your resource with `Locastic\ApiPlatformTranslationBundle\Model\AbstractTranslatable` - Add a `createTranslation()` method which returns a new object of the translation entity - Add a `translations` property: a `OneToMany` to the translation entity, indexed by locale, with the `translations` serialization group -- Add virtual fields for all translatable fields; their getters and setters delegate to the translation object +- Add virtual fields for all translatable fields; getters delegate to `getTranslation()` (current locale, with fallback), setters to `getOrCreateTranslation()` (exact locale, created and attached when missing) Example: ```php @@ -133,7 +142,7 @@ class Article extends AbstractTranslatable public function setTitle(string $title): void { - $this->getTranslation()->setTitle($title); + $this->getOrCreateTranslation()->setTitle($title); } protected function createTranslation(): TranslationInterface diff --git a/UPGRADE-2.1.md b/UPGRADE-2.1.md index dfd96e6..469f348 100644 --- a/UPGRADE-2.1.md +++ b/UPGRADE-2.1.md @@ -4,7 +4,31 @@ ## Deprecations -1. `Locastic\ApiPlatformTranslationBundle\DependencyInjection\ApiPlatformTranslationExtension` +1. Not setting the `api_platform_translation.auto_create_translations` option + explicitly is deprecated. It defaults to `true` (the 2.0 behavior: + `getTranslation()` creates and attaches a translation when the requested + locale is missing, which `cascade: persist` then inserts on the next flush, + so a read can write empty rows). In 3.0 the default flips to `false`, where + `getTranslation()` returns a detached, never-persisted translation instead. + Set the option explicitly to silence the deprecation and, if you opt into + `false`, change virtual setters to write through the new + `getOrCreateTranslation()` method: + + ```php + public function setTitle(string $title): void + { + $this->getOrCreateTranslation()->setTitle($title); + } + ``` + + `getOrCreateTranslation()` always creates and attaches the missing + translation for the exact locale (it never falls back to another locale, so + it cannot overwrite the fallback translation's content), regardless of the + `auto_create_translations` setting. Getters keep using `getTranslation()`. + Writes through the API (the `translations` payload) are unaffected by the + option: the denormalizer creates translations explicitly. + +2. `Locastic\ApiPlatformTranslationBundle\DependencyInjection\ApiPlatformTranslationExtension` is deprecated and will be removed in 3.0. The bundle now extends `AbstractBundle` and registers its extension itself. The class keeps working if you instantiate it directly, but stop referencing it: registering the @@ -23,5 +47,6 @@ These only affect you if you referenced bundle files by path: ## New configuration The bundle now has a configuration tree under `api_platform_translation` -(`enabled_locales`, `fallback_locale`, `locale_resolution`). All defaults -preserve 2.0 behavior; see the README "Configuration" section. +(`enabled_locales`, `fallback_locale`, `auto_create_translations`, +`locale_resolution`). All defaults preserve 2.0 behavior; see the README +"Configuration" section. diff --git a/config/services.php b/config/services.php index acecee1..694febc 100644 --- a/config/services.php +++ b/config/services.php @@ -26,6 +26,7 @@ ->args([ service('locastic_api_platform_translation.translation.translator'), param('locastic_api_platform_translation.fallback_locale'), + param('locastic_api_platform_translation.auto_create_translations'), ]) ->tag('doctrine.event_listener', ['event' => 'postLoad']) ->tag('doctrine.event_listener', ['event' => 'prePersist']); diff --git a/context7.json b/context7.json index 4ab7a8b..426c72d 100644 --- a/context7.json +++ b/context7.json @@ -19,7 +19,8 @@ "rules": [ "A translatable resource extends AbstractTranslatable, implements createTranslation(), and maps translations as OneToMany with indexBy: 'locale', cascade: ['persist'], orphanRemoval: true.", "The translation entity extends AbstractTranslation and holds all translatable fields plus a writable locale field.", - "Virtual getters and setters on the translatable delegate to getTranslation(), which resolves the current locale and falls back to the fallback locale.", + "Virtual getters on the translatable delegate to getTranslation(), which resolves the current locale and falls back to the fallback locale; virtual setters delegate to getOrCreateTranslation(), which targets the exact locale and creates the translation when missing.", + "Set auto_create_translations: false so reads never write to the database; the default (true) keeps the legacy behavior where getTranslation() attaches an empty translation for a missing locale, which cascade persist can insert on the next flush. The default flips to false in 3.0.", "Put the 'translations' serialization group on the translations collection, on every field of the translation entity, and in the normalizationContext of POST, PUT, and PATCH operations so writes return all translation objects.", "In write payloads, translations is an object keyed by locale, and each entry must repeat its 'locale' field: {\"translations\": {\"de\": {\"title\": \"...\", \"locale\": \"de\"}}}.", "Prefer PATCH (application/merge-patch+json) for editing translations: it updates only the submitted locales. PUT is a full replace and removes locales absent from the payload.", diff --git a/src/ApiPlatformTranslationBundle.php b/src/ApiPlatformTranslationBundle.php index d886dbe..935c123 100644 --- a/src/ApiPlatformTranslationBundle.php +++ b/src/ApiPlatformTranslationBundle.php @@ -27,6 +27,10 @@ public function configure(DefinitionConfigurator $definition): void ->info('Locale stamped as the fallback on loaded and persisted translatables. Defaults to kernel.default_locale.') ->defaultNull() ->end() + ->booleanNode('auto_create_translations') + ->info('Whether reading a translation that does not exist creates an empty one and attaches it to the entity (with cascade persist, a read can then insert empty rows). Set false so reads never write: missing translations just show empty fields. Defaults to true; the default flips to false in 3.0.') + ->defaultNull() + ->end() ->arrayNode('locale_resolution') ->info('Ordered list of sources the request locale is resolved from; the first source producing a locale wins. Remove a source to disable it.') ->performNoDeepMerging() @@ -43,13 +47,22 @@ public function configure(DefinitionConfigurator $definition): void } /** - * @param array{enabled_locales: list, fallback_locale: ?string, locale_resolution: list} $config + * @param array{enabled_locales: list, fallback_locale: ?string, auto_create_translations: ?bool, locale_resolution: list} $config */ public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { + if (null === $config['auto_create_translations']) { + trigger_deprecation( + 'locastic/api-platform-translation-bundle', + '2.1', + 'Not setting the "api_platform_translation.auto_create_translations" option is deprecated, its default will change from true to false in 3.0. Set it explicitly to keep the current behavior.', + ); + } + $container->parameters() ->set('locastic_api_platform_translation.enabled_locales', $config['enabled_locales'] ?: '%kernel.enabled_locales%') ->set('locastic_api_platform_translation.fallback_locale', $config['fallback_locale'] ?? '%kernel.default_locale%') + ->set('locastic_api_platform_translation.auto_create_translations', $config['auto_create_translations'] ?? true) ->set('locastic_api_platform_translation.locale_resolution', $config['locale_resolution']); $container->import('../config/services.php'); diff --git a/src/DependencyInjection/ApiPlatformTranslationExtension.php b/src/DependencyInjection/ApiPlatformTranslationExtension.php index 450da85..41a38fd 100644 --- a/src/DependencyInjection/ApiPlatformTranslationExtension.php +++ b/src/DependencyInjection/ApiPlatformTranslationExtension.php @@ -29,6 +29,7 @@ public function load(array $configs, ContainerBuilder $container): void { $container->setParameter('locastic_api_platform_translation.enabled_locales', '%kernel.enabled_locales%'); $container->setParameter('locastic_api_platform_translation.fallback_locale', '%kernel.default_locale%'); + $container->setParameter('locastic_api_platform_translation.auto_create_translations', true); $container->setParameter('locastic_api_platform_translation.locale_resolution', ['query_param', 'accept_language']); $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'config')); diff --git a/src/EventListener/AssignLocaleListener.php b/src/EventListener/AssignLocaleListener.php index 813ba8b..d821333 100644 --- a/src/EventListener/AssignLocaleListener.php +++ b/src/EventListener/AssignLocaleListener.php @@ -14,6 +14,7 @@ class AssignLocaleListener public function __construct( private Translator $translator, private string $defaultLocale = 'en', + private bool $autoCreateTranslations = true, ) { } @@ -47,5 +48,10 @@ private function assignLocale(LifecycleEventArgs $args): void $localeCode = $this->translator->loadCurrentLocale(); $object->setCurrentLocale($localeCode); $object->setFallbackLocale($this->defaultLocale); + + // Not on TranslatableInterface: adding a method there would break implementors not using the trait. + if (method_exists($object, 'setAutoCreateTranslations')) { + $object->setAutoCreateTranslations($this->autoCreateTranslations); + } } } diff --git a/src/Model/TranslatableTrait.php b/src/Model/TranslatableTrait.php index 42141dc..f88afcf 100644 --- a/src/Model/TranslatableTrait.php +++ b/src/Model/TranslatableTrait.php @@ -36,6 +36,7 @@ trait TranslatableTrait protected array $translationsCache = []; protected ?string $currentLocale = null; protected ?string $fallbackLocale = null; + protected bool $autoCreateTranslations = true; /** * @codeCoverageIgnore @@ -76,6 +77,10 @@ public function getTranslation(?string $locale = null): TranslationInterface $translation = $this->createTranslation(); $translation->setLocale($locale); + if (!$this->autoCreateTranslations) { + return $translation; + } + $this->addTranslation($translation); $this->translationsCache[$locale] = $translation; @@ -83,6 +88,35 @@ public function getTranslation(?string $locale = null): TranslationInterface return $translation; } + /** + * @psalm-return T + * + * @throws \RuntimeException + */ + public function getOrCreateTranslation(?string $locale = null): TranslationInterface + { + if ($this instanceof Proxy && !$this->__isInitialized()) { + $this->__load(); + } + + $locale = $locale ?: $this->currentLocale; + if (null === $locale) { + throw new \RuntimeException('No locale has been set and current locale is undefined.'); + } + + $translation = $this->matchTranslation($locale); + if (null !== $translation) { + return $translation; + } + + $translation = $this->createTranslation(); + $translation->setLocale($locale); + + $this->addTranslation($translation); + + return $translation; + } + /** * @psalm-return T|null */ @@ -186,6 +220,11 @@ public function setFallbackLocale(?string $fallbackLocale): void $this->fallbackLocale = $fallbackLocale; } + public function setAutoCreateTranslations(bool $autoCreateTranslations): void + { + $this->autoCreateTranslations = $autoCreateTranslations; + } + /** * Create resource translation model. * diff --git a/tests/DependencyInjection/ApiPlatformTranslationExtensionTest.php b/tests/DependencyInjection/ApiPlatformTranslationExtensionTest.php index 8c2a5f1..0b93604 100644 --- a/tests/DependencyInjection/ApiPlatformTranslationExtensionTest.php +++ b/tests/DependencyInjection/ApiPlatformTranslationExtensionTest.php @@ -27,6 +27,7 @@ public function testDeprecatedExtensionStillRegistersServices(): void $this->assertTrue($container->hasDefinition('locastic_api_platform_translation.serializer.translatable_denormalizer')); $this->assertTrue($container->hasDefinition('locastic_api_platform_translation.filter.translation_groups')); $this->assertSame('%kernel.enabled_locales%', $container->getParameter('locastic_api_platform_translation.enabled_locales')); + $this->assertTrue($container->getParameter('locastic_api_platform_translation.auto_create_translations')); $this->assertSame('api_platform_translation', $extension->getAlias()); } } diff --git a/tests/EventListener/AssignLocaleListenerTest.php b/tests/EventListener/AssignLocaleListenerTest.php index 91fe4a8..f8445de 100644 --- a/tests/EventListener/AssignLocaleListenerTest.php +++ b/tests/EventListener/AssignLocaleListenerTest.php @@ -75,6 +75,40 @@ public function testPrePersistNonTranslatableObjects(object $object): void $assignLocaleSubscriber->postLoad($args); } + /** + * @test postLoad + */ + public function testPostLoadStampsAutoCreateTranslations(): void + { + $translatable = new DummyTranslatable(); + $args = $this->createMock(LifecycleEventArgs::class); + $this->getObjectInfo($args, $translatable); + $this->loadCurrentLocale(); + + $assignLocaleSubscriber = new AssignLocaleListener($this->translator, 'en', false); + $assignLocaleSubscriber->postLoad($args); + + $translatable->getTranslation('fr'); + $this->assertCount(0, $translatable->getTranslations(), 'With auto-create disabled, reading a missing locale must not attach a translation.'); + } + + /** + * @test postLoad + */ + public function testPostLoadKeepsAutoCreateTranslationsByDefault(): void + { + $translatable = new DummyTranslatable(); + $args = $this->createMock(LifecycleEventArgs::class); + $this->getObjectInfo($args, $translatable); + $this->loadCurrentLocale(); + + $assignLocaleSubscriber = new AssignLocaleListener($this->translator); + $assignLocaleSubscriber->postLoad($args); + + $translatable->getTranslation('fr'); + $this->assertCount(1, $translatable->getTranslations()); + } + private function getObjectInfo(\PHPUnit\Framework\MockObject\MockObject $args, object $object): void { $args diff --git a/tests/Functional/ConfigurationTest.php b/tests/Functional/ConfigurationTest.php index 257a1e2..9d1e68a 100644 --- a/tests/Functional/ConfigurationTest.php +++ b/tests/Functional/ConfigurationTest.php @@ -51,12 +51,24 @@ public function testDefaultsWithBareFramework(): void $this->assertSame([], $container->getParameter('locastic_api_platform_translation.enabled_locales')); $this->assertSame('en', $container->getParameter('locastic_api_platform_translation.fallback_locale')); + $this->assertTrue($container->getParameter('locastic_api_platform_translation.auto_create_translations')); $this->assertSame( [Translator::RESOLUTION_QUERY_PARAM, Translator::RESOLUTION_ACCEPT_LANGUAGE], $container->getParameter('locastic_api_platform_translation.locale_resolution') ); } + public function testAutoCreateTranslationsCanBeDisabled(): void + { + self::$translationConfig = [ + 'auto_create_translations' => false, + ]; + self::bootKernel(); + $container = static::getContainer(); + + $this->assertFalse($container->getParameter('locastic_api_platform_translation.auto_create_translations')); + } + public function testDefaultsInheritFrameworkLocales(): void { self::$frameworkConfig = [ diff --git a/tests/Model/TranslatableTraitTest.php b/tests/Model/TranslatableTraitTest.php index ee8b13e..4dfa191 100644 --- a/tests/Model/TranslatableTraitTest.php +++ b/tests/Model/TranslatableTraitTest.php @@ -104,6 +104,99 @@ public function testGetTranslationCreatesAndAddsMissingTranslation(): void $this->assertCount(1, $dummyTranslatable->getTranslations()); } + /** + * @test getTranslation + */ + public function testGetTranslationWithAutoCreateDisabledReturnsDetachedTranslation(): void + { + $dummyTranslatable = $this->setTranslatable('es', 'en'); + $dummyTranslatable->setAutoCreateTranslations(false); + + $translation = $dummyTranslatable->getTranslation('fr'); + + $this->assertSame('fr', $translation->getLocale()); + $this->assertNull($translation->getTranslatable()); + $this->assertCount(0, $dummyTranslatable->getTranslations()); + $this->assertNotSame($translation, $dummyTranslatable->getTranslation('fr'), 'A detached translation must not be cached.'); + } + + /** + * @test getTranslation + */ + public function testGetTranslationWithAutoCreateDisabledStillReturnsExistingAndFallbackTranslations(): void + { + $dummyTranslatable = $this->setTranslatable('es', 'en'); + $dummyTranslatable->setAutoCreateTranslations(false); + $this->setTranslation('en', 'english', $dummyTranslatable); + + $this->assertSame('english', $dummyTranslatable->getTranslation('en')->getTranslation()); + $this->assertSame('english', $dummyTranslatable->getTranslation('it')->getTranslation()); + } + + /** + * @test getOrCreateTranslation + */ + public function testGetOrCreateTranslationAttachesEvenWithAutoCreateDisabled(): void + { + $dummyTranslatable = $this->setTranslatable('es', 'en'); + $dummyTranslatable->setAutoCreateTranslations(false); + + $translation = $dummyTranslatable->getOrCreateTranslation('fr'); + + $this->assertSame('fr', $translation->getLocale()); + $this->assertSame($dummyTranslatable, $translation->getTranslatable()); + $this->assertCount(1, $dummyTranslatable->getTranslations()); + $this->assertSame($translation, $dummyTranslatable->getOrCreateTranslation('fr')); + } + + /** + * @test getOrCreateTranslation + */ + public function testGetOrCreateTranslationReturnsExistingTranslation(): void + { + $dummyTranslatable = $this->setTranslatable('es', 'en'); + $english = $this->setTranslation('en', 'english', $dummyTranslatable); + $dummyTranslatable->addTranslation($english); + + $this->assertSame($english, $dummyTranslatable->getOrCreateTranslation('en')); + } + + /** + * @test getOrCreateTranslation + */ + public function testGetOrCreateTranslationDoesNotFallBackToAnotherLocale(): void + { + $dummyTranslatable = $this->setTranslatable('es', 'en'); + $dummyTranslatable->addTranslation($this->setTranslation('en', 'english', $dummyTranslatable)); + + $translation = $dummyTranslatable->getOrCreateTranslation('it'); + + $this->assertSame('it', $translation->getLocale()); + $this->assertNull($translation->getTranslation()); + $this->assertCount(2, $dummyTranslatable->getTranslations()); + } + + /** + * @test getOrCreateTranslation + */ + public function testGetOrCreateTranslationUsesCurrentLocaleByDefault(): void + { + $dummyTranslatable = $this->setTranslatable('es', 'en'); + + $this->assertSame('es', $dummyTranslatable->getOrCreateTranslation()->getLocale()); + } + + /** + * @test getOrCreateTranslation + */ + public function testGetOrCreateTranslationWithoutLocales(): void + { + $dummyTranslatable = $this->setTranslatable(null, null); + + $this->expectException(\RuntimeException::class); + $dummyTranslatable->getOrCreateTranslation(); + } + /** * @test hasTranslation */