diff --git a/src/Attribute/TranslatedProperty.php b/src/Attribute/TranslatedProperty.php new file mode 100644 index 0000000..cbb43ef --- /dev/null +++ b/src/Attribute/TranslatedProperty.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Webfactory\Bundle\PolyglotBundle\Attribute; + +use Attribute; + +#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] +final class TranslatedProperty +{ + public function __construct( + private readonly string $propertyName, + private readonly ?string $translationFieldname = null, + ) { + } + + public function getPropertyName(): string + { + return $this->propertyName; + } + + public function getTranslationFieldname(): ?string + { + return $this->translationFieldname; + } +} diff --git a/src/Doctrine/TranslatableClassMetadata.php b/src/Doctrine/TranslatableClassMetadata.php index 0b13120..8ee6936 100644 --- a/src/Doctrine/TranslatableClassMetadata.php +++ b/src/Doctrine/TranslatableClassMetadata.php @@ -13,6 +13,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ClassMetadataFactory; use Doctrine\Persistence\Mapping\RuntimeReflectionService; +use InvalidArgumentException; use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionProperty; @@ -171,7 +172,7 @@ private function assertAttributesAreComplete(string $class): void } if (0 === \count($this->translatedProperties)) { - throw new RuntimeException('No translatable properties attributed with #[Polyglot\Translatable] were found'); + throw new RuntimeException('No translatable properties attributed with #[Polyglot\Translatable] (at the property level) or #[Polyglot\TranslatedProperty] (at the class level) were found'); } if (null === $this->primaryLocale) { @@ -187,30 +188,44 @@ private function findTranslatedProperties(ClassMetadata $cm, ClassMetadataFactor $reflectionService = $classMetadataFactory->getReflectionService(); $translationClassMetadata = $classMetadataFactory->getMetadataFor($this->translationClass->getName()); + $reflectionClass = $cm->getReflectionClass(); - /* Iterate all properties of the class, not only those mapped by Doctrine */ - foreach ($cm->getReflectionClass()->getProperties() as $reflectionProperty) { - $propertyName = $reflectionProperty->name; + /* + Collect all (propertyName => translationFieldname) candidates from both sources. + Using propertyName as key ensures deduplication when both sources declare the same property. + */ + $candidates = []; // propertyName => translationFieldname|null - /* - If the property is inherited from a parent class, and our parent entity class - already contains that declaration, we need not include it. - */ - $declaringClass = $reflectionProperty->getDeclaringClass()->name; - if ($declaringClass !== $cm->name && $cm->parentClasses && is_a($cm->parentClasses[0], $declaringClass, true)) { + /* Property-level #[Translatable] attributes */ + foreach ($reflectionClass->getProperties() as $reflectionProperty) { + $attributes = $reflectionProperty->getAttributes(Attribute\Translatable::class); + if (!$attributes || $this->isDeclaredByParentEntity($reflectionProperty, $cm)) { continue; } - $attributes = $reflectionProperty->getAttributes(Attribute\Translatable::class); + $candidates[$reflectionProperty->name] = $attributes[0]->newInstance()->getTranslationFieldname(); + } + + /* Class-level #[TranslatedProperty] attributes */ + foreach ($reflectionClass->getAttributes(Attribute\TranslatedProperty::class) as $classAttribute) { + $attribute = $classAttribute->newInstance(); + $propertyName = $attribute->getPropertyName(); + + if (!$reflectionClass->hasProperty($propertyName)) { + throw new InvalidArgumentException(\sprintf('Property "%s" not found in class "%s" (declared via #[TranslatedProperty]).', $propertyName, $cm->name)); + } - if (!$attributes) { + if ($this->isDeclaredByParentEntity($reflectionClass->getProperty($propertyName), $cm)) { continue; } - $attribute = $attributes[0]->newInstance(); + $candidates[$propertyName] = $attribute->getTranslationFieldname(); + } + + /* Register all collected candidates */ + foreach ($candidates as $propertyName => $translationFieldname) { $this->translatedProperties[$propertyName] = $reflectionService->getAccessibleProperty($cm->name, $propertyName); - $translationFieldname = $attribute->getTranslationFieldname() ?: $propertyName; - $this->translationFieldMapping[$propertyName] = $reflectionService->getAccessibleProperty($translationClassMetadata->name, $translationFieldname); + $this->translationFieldMapping[$propertyName] = $reflectionService->getAccessibleProperty($translationClassMetadata->name, $translationFieldname ?: $propertyName); } } @@ -250,6 +265,17 @@ private function findPrimaryLocale(ClassMetadata $cm): void } } + /* + Returns true if the property is declared in a parent class that is already covered + by our parent entity's metadata, so we need not include it again. + */ + private function isDeclaredByParentEntity(ReflectionProperty $property, ClassMetadata $cm): bool + { + $declaringClass = $property->getDeclaringClass()->name; + + return $declaringClass !== $cm->name && $cm->parentClasses && is_a($cm->parentClasses[0], $declaringClass, true); + } + private function parseTranslationsEntity(ClassMetadata $cm): void { foreach ($cm->fieldMappings as $fieldName => $mapping) { diff --git a/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclass.php b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclass.php new file mode 100644 index 0000000..1ff0cd1 --- /dev/null +++ b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclass.php @@ -0,0 +1,33 @@ +id; + } + + public function getText(): TranslatableInterface|string|null + { + return $this->text; + } +} diff --git a/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntity.php b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntity.php new file mode 100644 index 0000000..31f1f6a --- /dev/null +++ b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntity.php @@ -0,0 +1,34 @@ +translations = new ArrayCollection(); + } + + public function setText(TranslatableInterface $text): void + { + $this->text = $text; + } +} diff --git a/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntityTranslation.php b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntityTranslation.php new file mode 100644 index 0000000..dff97da --- /dev/null +++ b/tests/Fixtures/Entity/EntityInheritance/EntityInheritance_MappedSuperclassEntityTranslation.php @@ -0,0 +1,25 @@ +setTranslation('Basistext', 'de_DE'); + $entity->setText($t); + + self::import([$entity]); + + $loaded = $this->entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId()); + + self::assertSame('base text', $loaded->getText()->translate('en_GB')); + self::assertSame('Basistext', $loaded->getText()->translate('de_DE')); + } + + public function testAddTranslation(): void + { + $entityManager = $this->entityManager; + + $entity = new EntityInheritance_MappedSuperclassEntity(); + $entity->setText(new Translatable('base text')); + self::import([$entity]); + + $loaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId()); + $loaded->getText()->setTranslation('Basistext', 'de_DE'); + $entityManager->flush(); + + $entityManager->clear(); + $reloaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId()); + + self::assertSame('base text', $reloaded->getText()->translate('en_GB')); + self::assertSame('Basistext', $reloaded->getText()->translate('de_DE')); + } + + public function testUpdateTranslations(): void + { + $entityManager = $this->entityManager; + + $entity = new EntityInheritance_MappedSuperclassEntity(); + $t = new Translatable('old text'); + $t->setTranslation('alter Text', 'de_DE'); + $entity->setText($t); + self::import([$entity]); + + $loaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId()); + $loaded->getText()->setTranslation('new text'); + $loaded->getText()->setTranslation('neuer Text', 'de_DE'); + $entityManager->flush(); + + $entityManager->clear(); + $reloaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId()); + + self::assertSame('new text', $reloaded->getText()->translate('en_GB')); + self::assertSame('neuer Text', $reloaded->getText()->translate('de_DE')); + } +}