diff --git a/README.md b/README.md index e1a8e5d..dc7b472 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,57 @@ symfony console object-translation:import translations_fr.csv fr The first argument is the path to the CSV file, and the second argument is the locale of the translations in that file. +## Translation Management API + +If you want to build a custom administration interface for managing translations in your application (e.g. using Symfony UX, React or plain PHP), the bundle provides a stable `TranslationManagerInterface`. This interface allows you to access translations programmatically without needing to interact directly with the database or internal services. + +### `TranslationManager` Service + +You can inject `TranslationManagerInterface` (an alias for `TranslationManager`) to perform CRUD operations, retrieve translation status, or invalidate the cache. + +```php +use SymfonyCasts\ObjectTranslationBundle\TranslationManagerInterface; + +class TranslationAdminController +{ + public function edit(Product $product, TranslationManagerInterface $translationManager) + { + // Save a translation for a specific field and locale + $translationManager->saveTranslation($product, 'sk', 'name', 'Názov produktu'); + + // Save multiple translations at once + $translationManager->saveTranslations($product, 'sk', [ + 'name' => 'Názov produktu', + 'description' => 'Dlhý popis produktu...', + ]); + + // Get all saved translations for a given entity and locale + $translations = $translationManager->findTranslations($product, 'sk'); // returns ['name' => '...', ...] + + // Get translation status (which fields are filled and which are missing) + $status = $translationManager->getTranslationStatus($product, 'sk'); + $status->getCompletionPercentage(); // e.g. 0.85 (85%) + $status->missingFields; // list of fields that are not yet translated + + // Manually invalidate cache for this entity + $translationManager->invalidateCacheForEntity($product); + } +} +``` + +For automatic discovery of translatable entities, you can use: + +```php +// Get list of all classes marked with #[Translatable] attribute +$types = $translationManager->getTranslatableTypes(); // returns list + +// Get fields for a specific class +$fields = $translationManager->getTranslatableFields(Product::class); + +// Get all objects of a given class (useful for lists in administration) +$objects = $translationManager->getObjectsForType(Product::class); +``` + ## Translation Caching For performance, translations are cached. By default, they use your `cache.app` pool diff --git a/config/services.php b/config/services.php index f4d7818..db0c882 100644 --- a/config/services.php +++ b/config/services.php @@ -2,10 +2,13 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Component\Cache\Adapter\NullAdapter; use SymfonyCasts\ObjectTranslationBundle\Command\ObjectTranslationExportCommand; use SymfonyCasts\ObjectTranslationBundle\Command\ObjectTranslationImportCommand; use SymfonyCasts\ObjectTranslationBundle\Command\ObjectTranslationWarmupCommand; use SymfonyCasts\ObjectTranslationBundle\ObjectTranslator; +use SymfonyCasts\ObjectTranslationBundle\TranslationManager; +use SymfonyCasts\ObjectTranslationBundle\TranslationManagerInterface; use SymfonyCasts\ObjectTranslationBundle\TranslatableMappingManager; use SymfonyCasts\ObjectTranslationBundle\Twig\ObjectTranslatorExtension; @@ -15,11 +18,23 @@ ->args([ service('translation.locale_switcher'), param('kernel.default_locale'), - service('.symfonycasts.object_translator.mapping_manager') + service('.symfonycasts.object_translator.mapping_manager'), + service('.symfonycasts.object_translator.cache'), ]) ->tag('twig.runtime') ->alias(ObjectTranslator::class, 'symfonycasts.object_translator') + ->public() + + ->set('symfonycasts.object_translation_manager', TranslationManager::class) + ->public() + ->args([ + service('.symfonycasts.object_translator.mapping_manager'), + service('doctrine'), + service('.symfonycasts.object_translator.cache'), + ]) + + ->alias(TranslationManagerInterface::class, 'symfonycasts.object_translation_manager') ->set('.symfonycasts.object_translator.mapping_manager', TranslatableMappingManager::class) ->args([ @@ -27,6 +42,8 @@ service('doctrine'), ]) + ->set('.symfonycasts.object_translator.cache', NullAdapter::class) + ->set('.symfonycasts.object_translator.warmup_command', ObjectTranslationWarmupCommand::class) ->args([ service('symfonycasts.object_translator'), diff --git a/src/Dto/TranslatableTypeInfo.php b/src/Dto/TranslatableTypeInfo.php new file mode 100644 index 0000000..03fe6ce --- /dev/null +++ b/src/Dto/TranslatableTypeInfo.php @@ -0,0 +1,14 @@ + */ + public readonly array $fields, // translatable property names + ) { + } +} diff --git a/src/Dto/TranslationStatus.php b/src/Dto/TranslationStatus.php new file mode 100644 index 0000000..e6e0100 --- /dev/null +++ b/src/Dto/TranslationStatus.php @@ -0,0 +1,33 @@ + */ + public readonly array $translatedFields, + /** @var list */ + public readonly array $missingFields, + ) { + } + + public function isFullyTranslated(): bool + { + return 0 === \count($this->missingFields); + } + + public function getCompletionPercentage(): float + { + $total = \count($this->translatedFields) + \count($this->missingFields); + + if (0 === $total) { + return 1.0; + } + + return \count($this->translatedFields) / $total; + } +} diff --git a/src/ObjectTranslationBundle.php b/src/ObjectTranslationBundle.php index 3777f99..f098838 100644 --- a/src/ObjectTranslationBundle.php +++ b/src/ObjectTranslationBundle.php @@ -6,7 +6,6 @@ use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; -use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; use SymfonyCasts\ObjectTranslationBundle\Model\Translation; @@ -60,9 +59,10 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $container->import('../config/services.php'); $objectTranslatorDef = $builder->getDefinition('symfonycasts.object_translator'); + $translationManagerDef = $builder->getDefinition('symfonycasts.object_translation_manager'); if ($config['cache']['enabled']) { - $objectTranslatorDef->setArgument(3, new Reference($config['cache']['pool'])); + $builder->setAlias('.symfonycasts.object_translator.cache', $config['cache']['pool']); $objectTranslatorDef->setArgument(4, $config['cache']['ttl']); } diff --git a/src/ObjectTranslator.php b/src/ObjectTranslator.php index 54ee986..dc31ac3 100644 --- a/src/ObjectTranslator.php +++ b/src/ObjectTranslator.php @@ -54,7 +54,11 @@ private function translationsFor(object $object, string $locale, bool $forceRefr "object_translation.{$locale}.{$type}.{$id}", function (ItemInterface $item) use ($locale, $type, $id) { if ($this->cache instanceof TagAwareCacheInterface) { - $item->tag(['object-translation', "object-translation-{$type}"]); + $item->tag([ + 'object-translation', + "object-translation-{$type}", + "object-translation-{$type}-{$id}", + ]); } if ($this->cacheTtl) { diff --git a/src/TranslatableMappingManager.php b/src/TranslatableMappingManager.php index 1b3e6b7..612a4e6 100644 --- a/src/TranslatableMappingManager.php +++ b/src/TranslatableMappingManager.php @@ -2,6 +2,7 @@ namespace SymfonyCasts\ObjectTranslationBundle; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\Proxy; use SymfonyCasts\ObjectTranslationBundle\Mapping\Translatable; @@ -123,4 +124,86 @@ public function upsert(string $type, string $id, string $locale, string $field, $om->persist($translation); $om->flush(); } + + public function delete(string $type, string $id, string $locale, ?string $field = null): void + { + $om = $this->doctrine->getManagerForClass($this->translationClass); + + if (!$om instanceof EntityManagerInterface) { + throw new \LogicException(sprintf('Object manager for class "%s" must be an instance of EntityManagerInterface', $this->translationClass)); + } + + $qb = $om->createQueryBuilder() + ->delete($this->translationClass, 't') + ->where('t.objectType = :type') + ->andWhere('t.objectId = :id') + ->andWhere('t.locale = :locale') + ->setParameter('type', $type) + ->setParameter('id', $id) + ->setParameter('locale', $locale); + + if ($field) { + $qb->andWhere('t.field = :field') + ->setParameter('field', $field); + } + + $qb->getQuery()->execute(); + } + + public function deleteForType(string $type, ?string $locale = null): void + { + $om = $this->doctrine->getManagerForClass($this->translationClass); + + if (!$om instanceof EntityManagerInterface) { + throw new \LogicException(sprintf('Object manager for class "%s" must be an instance of EntityManagerInterface', $this->translationClass)); + } + + $qb = $om->createQueryBuilder() + ->delete($this->translationClass, 't') + ->where('t.objectType = :type') + ->setParameter('type', $type); + + if ($locale) { + $qb->andWhere('t.locale = :locale') + ->setParameter('locale', $locale); + } + + $qb->getQuery()->execute(); + } + + public function getTranslatableTypeMap(): array + { + $map = []; + + foreach ($this->doctrine->getManagers() as $om) { + foreach ($om->getMetadataFactory()->getAllMetadata() as $metadata) { + $class = $metadata->getName(); + $reflectionClass = new \ReflectionClass($class); + $attribute = $reflectionClass->getAttributes(Translatable::class)[0] ?? null; + + if (!$attribute) { + continue; + } + + $type = $attribute->newInstance()->name; + $map[$type] = $class; + } + } + + return $map; + } + + public function getTranslatableFieldsForClass(string $class): array + { + $reflectionClass = new \ReflectionClass($class); + $fields = []; + + foreach ($reflectionClass->getProperties() as $property) { + if ($property->getAttributes(TranslatableProperty::class)) { + $fields[] = $property->getName(); + } + } + + return $fields; + } } diff --git a/src/TranslationManager.php b/src/TranslationManager.php new file mode 100644 index 0000000..b0144c8 --- /dev/null +++ b/src/TranslationManager.php @@ -0,0 +1,171 @@ +mappingManager->getTranslatableTypeMap(); + $types = []; + + foreach ($map as $name => $class) { + $types[] = new TranslatableTypeInfo( + $name, + $class, + $this->mappingManager->getTranslatableFieldsForClass($class) + ); + } + + return $types; + } + + public function getTranslatableFields(string $class): array + { + return $this->mappingManager->getTranslatableFieldsForClass($class); + } + + public function getObjectsForType(string $class): iterable + { + $manager = $this->doctrine->getManagerForClass($class); + + if (!$manager instanceof \Doctrine\ORM\EntityManagerInterface) { + throw new \LogicException(sprintf('Manager for class "%s" must be an instance of EntityManagerInterface.', $class)); + } + + return $manager->createQueryBuilder() + ->select('o') + ->from($class, 'o') + ->getQuery() + ->toIterable(); + } + + public function findTranslation(object $entity, string $locale, string $field): ?string + { + $translations = $this->findTranslations($entity, $locale); + + return $translations[$field] ?? null; + } + + public function findTranslations(object $entity, string $locale): array + { + $type = $this->mappingManager->translatableTypeFor($entity); + $id = $this->mappingManager->idFor($entity); + + return $this->mappingManager->translationsFor($locale, $type, $id); + } + + public function saveTranslation(object $entity, string $locale, string $field, string $value): void + { + $this->saveTranslations($entity, $locale, [$field => $value]); + } + + public function saveTranslations(object $entity, string $locale, array $fields): void + { + $type = $this->mappingManager->translatableTypeFor($entity); + $id = $this->mappingManager->idFor($entity); + + foreach ($fields as $field => $value) { + $this->mappingManager->upsert($type, $id, $locale, $field, $value); + } + + $this->invalidateCacheForEntity($entity); + } + + public function deleteTranslation(object $entity, string $locale, ?string $field = null): void + { + $type = $this->mappingManager->translatableTypeFor($entity); + $id = $this->mappingManager->idFor($entity); + + $this->mappingManager->delete($type, $id, $locale, $field); + $this->invalidateCacheForEntity($entity); + } + + public function deleteTranslationsForType(string $class, ?string $locale = null): void + { + $map = $this->mappingManager->getTranslatableTypeMap(); + $type = array_search($class, $map, true); + + if (!$type) { + throw new \LogicException(sprintf('Class "%s" is not translatable.', $class)); + } + + $this->mappingManager->deleteForType($type, $locale); + $this->invalidateCacheForType($class); + } + + public function getTranslationStatus(object $entity, string $locale): TranslationStatus + { + $type = $this->mappingManager->translatableTypeFor($entity); + $id = $this->mappingManager->idFor($entity); + $allFields = $this->mappingManager->getTranslatableFieldsForClass($entity::class); + $translations = $this->findTranslations($entity, $locale); + + $translatedFields = []; + $missingFields = []; + + foreach ($allFields as $field) { + if (isset($translations[$field])) { + $translatedFields[] = $field; + } else { + $missingFields[] = $field; + } + } + + return new TranslationStatus( + $type, + $id, + $locale, + $translatedFields, + $missingFields + ); + } + + public function invalidateCacheForEntity(object $entity): void + { + if (!$this->cache instanceof TagAwareCacheInterface) { + return; + } + + $type = $this->mappingManager->translatableTypeFor($entity); + $id = $this->mappingManager->idFor($entity); + + $this->cache->invalidateTags(["object-translation-{$type}-{$id}"]); + } + + public function invalidateCacheForType(string $class): void + { + if (!$this->cache instanceof TagAwareCacheInterface) { + return; + } + + $map = $this->mappingManager->getTranslatableTypeMap(); + $type = array_search($class, $map, true); + + if ($type) { + $this->cache->invalidateTags(["object-translation-{$type}"]); + } + } + + public function invalidateAllTranslationCache(): void + { + if (!$this->cache instanceof TagAwareCacheInterface) { + return; + } + + $this->cache->invalidateTags(['object-translation']); + } +} diff --git a/src/TranslationManagerInterface.php b/src/TranslationManagerInterface.php new file mode 100644 index 0000000..61d7053 --- /dev/null +++ b/src/TranslationManagerInterface.php @@ -0,0 +1,69 @@ + */ + public function getTranslatableTypes(): array; + + /** + * @param class-string $class e.g. Product::class + * + * @return list + */ + public function getTranslatableFields(string $class): array; + + /** + * @param class-string $class e.g. Product::class + * + * @return iterable An iterator for objects of the given type, for memory efficiency + */ + public function getObjectsForType(string $class): iterable; + + // --- Read (entity-level) --- + + public function findTranslation(object $entity, string $locale, string $field): ?string; + + /** @return array field => translated value */ + public function findTranslations(object $entity, string $locale): array; + + // --- Write (entity-level) --- + + public function saveTranslation(object $entity, string $locale, string $field, string $value): void; + + /** + * @param array $fields field => value + */ + public function saveTranslations(object $entity, string $locale, array $fields): void; + + // --- Delete --- + + /** null $field = delete all fields for this entity+locale */ + public function deleteTranslation(object $entity, string $locale, ?string $field = null): void; + + /** + * Delete all translations for a class; optionally scoped to a locale. + * + * @param class-string $class e.g. Product::class + */ + public function deleteTranslationsForType(string $class, ?string $locale = null): void; + + // --- Status (entity-level) --- + + public function getTranslationStatus(object $entity, string $locale): Dto\TranslationStatus; + + // --- Cache --- + + public function invalidateCacheForEntity(object $entity): void; + + /** @param class-string $class e.g. Product::class */ + public function invalidateCacheForType(string $class): void; + + public function invalidateAllTranslationCache(): void; +} diff --git a/tests/Fixture/TestKernel.php b/tests/Fixture/TestKernel.php index b75c660..79dbac1 100644 --- a/tests/Fixture/TestKernel.php +++ b/tests/Fixture/TestKernel.php @@ -29,10 +29,21 @@ private function configureContainer(ContainerConfigurator $container, LoaderInte { $builder->loadFromExtension('framework', [ 'test' => true, + 'cache' => [ + 'pools' => [ + 'cache.object_translation' => [ + 'adapter' => 'cache.adapter.array', + 'tags' => true, + ], + ], + ], ]); $builder->loadFromExtension('symfonycasts_object_translation', [ 'translation_class' => Translation::class, + 'cache' => [ + 'pool' => 'cache.object_translation', + ], ]); $builder->loadFromExtension('doctrine', [ diff --git a/tests/Integration/TranslatableMappingManagerIntegrationTest.php b/tests/Integration/TranslatableMappingManagerIntegrationTest.php new file mode 100644 index 0000000..9dff2d6 --- /dev/null +++ b/tests/Integration/TranslatableMappingManagerIntegrationTest.php @@ -0,0 +1,110 @@ +get(ObjectTranslator::class); + $reflection = new \ReflectionClass($translator); + $property = $reflection->getProperty('mappingManager'); + $this->manager = $property->getValue($translator); + } + + public function testDeleteSingleField(): void + { + persist(Translation::class, [ + 'objectType' => 'entity1', + 'objectId' => '1', + 'locale' => 'fr', + 'field' => 'property1', + 'value' => 'valeur1', + ]); + persist(Translation::class, [ + 'objectType' => 'entity1', + 'objectId' => '1', + 'locale' => 'fr', + 'field' => 'property1_extra', + 'value' => 'valeur1_extra', + ]); + + $this->manager->delete('entity1', '1', 'fr', 'property1'); + + $repo = repository(Translation::class); + $this->assertCount(1, $repo->findAll()); + $this->assertNull($repo->findOneBy(['field' => 'property1'])); + $this->assertNotNull($repo->findOneBy(['field' => 'property1_extra'])); + } + + public function testDeleteAllFieldsForEntityAndLocale(): void + { + persist(Translation::class, [ + 'objectType' => 'entity1', + 'objectId' => '1', + 'locale' => 'fr', + 'field' => 'property1', + 'value' => 'valeur1', + ]); + persist(Translation::class, [ + 'objectType' => 'entity1', + 'objectId' => '1', + 'locale' => 'fr', + 'field' => 'property1_extra', + 'value' => 'valeur1_extra', + ]); + persist(Translation::class, [ + 'objectType' => 'entity1', + 'objectId' => '1', + 'locale' => 'en', + 'field' => 'property1', + 'value' => 'value1', + ]); + + $this->manager->delete('entity1', '1', 'fr'); + + $repo = repository(Translation::class); + $this->assertCount(1, $repo->findAll()); + $this->assertNotNull($repo->findOneBy(['locale' => 'en'])); + } + + public function testDeleteForType(): void + { + persist(Translation::class, ['objectType' => 'entity1', 'objectId' => '1', 'locale' => 'fr', 'field' => 'f', 'value' => 'v']); + persist(Translation::class, ['objectType' => 'entity1', 'objectId' => '2', 'locale' => 'fr', 'field' => 'f', 'value' => 'v']); + persist(Translation::class, ['objectType' => 'other', 'objectId' => '1', 'locale' => 'fr', 'field' => 'f', 'value' => 'v']); + + $this->manager->deleteForType('entity1'); + + $repo = repository(Translation::class); + $this->assertCount(1, $repo->findAll()); + $this->assertNotNull($repo->findOneBy(['objectType' => 'other'])); + } + + public function testDeleteForTypeAndLocale(): void + { + persist(Translation::class, ['objectType' => 'entity1', 'objectId' => '1', 'locale' => 'fr', 'field' => 'f', 'value' => 'v']); + persist(Translation::class, ['objectType' => 'entity1', 'objectId' => '1', 'locale' => 'en', 'field' => 'f', 'value' => 'v']); + + $this->manager->deleteForType('entity1', 'fr'); + + $repo = repository(Translation::class); + $this->assertCount(1, $repo->findAll()); + $this->assertNotNull($repo->findOneBy(['locale' => 'en'])); + } +} diff --git a/tests/Integration/TranslationManagerTest.php b/tests/Integration/TranslationManagerTest.php new file mode 100644 index 0000000..91f5a5e --- /dev/null +++ b/tests/Integration/TranslationManagerTest.php @@ -0,0 +1,120 @@ +manager = self::getContainer()->get(TranslationManagerInterface::class); + } + + public function testGetTranslatableTypes(): void + { + $types = $this->manager->getTranslatableTypes(); + + $this->assertCount(1, $types); + $this->assertInstanceOf(TranslatableTypeInfo::class, $types[0]); + $this->assertSame('entity1', $types[0]->name); + $this->assertSame(Entity1::class, $types[0]->class); + $this->assertSame(['property1'], $types[0]->fields); + } + + public function testGetTranslatableFields(): void + { + $fields = $this->manager->getTranslatableFields(Entity1::class); + $this->assertSame(['property1'], $fields); + } + + public function testCRUDTranslations(): void + { + $entity = persist(Entity1::class, ['property1' => 'v1']); + + // Save + $this->manager->saveTranslation($entity, 'de', 'property1', 'de_v1'); + + // Find + $this->assertSame('de_v1', $this->manager->findTranslation($entity, 'de', 'property1')); + $this->assertSame(['property1' => 'de_v1'], $this->manager->findTranslations($entity, 'de')); + + // Status + $status = $this->manager->getTranslationStatus($entity, 'de'); + $this->assertInstanceOf(TranslationStatus::class, $status); + $this->assertSame(['property1'], $status->translatedFields); + $this->assertEmpty($status->missingFields); + $this->assertTrue($status->isFullyTranslated()); + $this->assertEquals(1.0, $status->getCompletionPercentage()); + + // Delete + $this->manager->deleteTranslation($entity, 'de', 'property1'); + $this->assertNull($this->manager->findTranslation($entity, 'de', 'property1')); + } + + public function testInvalidateCache(): void + { + $entity = persist(Entity1::class, ['property1' => 'v1']); + persist(Translation::class, [ + 'objectType' => 'entity1', + 'objectId' => $entity->id, + 'locale' => 'de', + 'field' => 'property1', + 'value' => 'old_value', + ]); + + $translator = self::getContainer()->get(ObjectTranslator::class); + + // Prime cache + $translated = $translator->translate($entity, 'de'); + $this->assertSame('old_value', $translated->property1); + + // Update translation directly in DB to bypass manager auto-invalidation + $t = repository(Translation::class)->findOneBy(['locale' => 'de']); + $t->value = 'new_value'; + self::getContainer()->get('doctrine')->getManager()->flush(); + + // Still old value due to cache + $translated = $translator->translate($entity, 'de'); + $this->assertSame('old_value', $translated->property1); + + // Invalidate + $this->manager->invalidateCacheForEntity($entity); + + // Now should be new value + $translated = $translator->translate($entity, 'de'); + $this->assertSame('new_value', $translated->property1); + } + + public function testGetObjectsForType(): void + { + persist(Entity1::class, ['property1' => 'v1']); + persist(Entity1::class, ['property1' => 'v2']); + + $objects = $this->manager->getObjectsForType(Entity1::class); + + $this->assertInstanceOf(\Generator::class, $objects); + $count = 0; + foreach ($objects as $object) { + $this->assertInstanceOf(Entity1::class, $object); + ++$count; + } + $this->assertSame(2, $count); + } +} diff --git a/tests/Unit/TranslatableMappingManagerTest.php b/tests/Unit/TranslatableMappingManagerTest.php new file mode 100644 index 0000000..3f7c23f --- /dev/null +++ b/tests/Unit/TranslatableMappingManagerTest.php @@ -0,0 +1,39 @@ +doctrine = $this->createMock(ManagerRegistry::class); + $this->manager = new TranslatableMappingManager( + 'App\Entity\Translation', + $this->doctrine + ); + } + + public function testGetTranslatableFieldsForClass(): void + { + $fields = $this->manager->getTranslatableFieldsForClass(Entity1::class); + $this->assertEquals(['property1'], $fields); + } + + public function testGetTranslatableTypeMap(): void + { + $this->doctrine->method('getManagers')->willReturn([]); + + // This test might be more complex because allTranslatableObjects() iterates over managers + // and metadata. Let's see if we can mock that easily or if it's better in Integration test. + $map = $this->manager->getTranslatableTypeMap(); + $this->assertIsArray($map); + } +}