Skip to content

Commit 1273914

Browse files
authored
Add class-level #[TranslatedProperty] configuration option (#89)
This PR introduces a new `#[Polyglot\TranslatedProperty('propertyName')]` PHP attribute that can be placed at the class level to declare the translatable properties. This is a useful alternative to placing `#[Polyglot\Translatable]` directly on the property in the case of Doctrine ORM mapped superclasses: Base classes (potentially provided by other libraries) can be extended, and it is left to the _extending_ code to add Polyglot support, without touching the superclass and without re-defining properties (which is not possible with Doctrine ORM). See the `EntityInheritance_MappedSuperclass` fixture class added here for an example. Note, however, that this class needs to use a union type including `TranslatableInterface` (or no property type at all) to allow subclasses to make fields translatable.
1 parent 0b71665 commit 1273914

File tree

6 files changed

+249
-15
lines changed

6 files changed

+249
-15
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
/*
4+
* (c) webfactory GmbH <info@webfactory.de>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace Webfactory\Bundle\PolyglotBundle\Attribute;
11+
12+
use Attribute;
13+
14+
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
15+
final class TranslatedProperty
16+
{
17+
public function __construct(
18+
private readonly string $propertyName,
19+
private readonly ?string $translationFieldname = null,
20+
) {
21+
}
22+
23+
public function getPropertyName(): string
24+
{
25+
return $this->propertyName;
26+
}
27+
28+
public function getTranslationFieldname(): ?string
29+
{
30+
return $this->translationFieldname;
31+
}
32+
}

src/Doctrine/TranslatableClassMetadata.php

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use Doctrine\ORM\Mapping\ClassMetadata;
1414
use Doctrine\ORM\Mapping\ClassMetadataFactory;
1515
use Doctrine\Persistence\Mapping\RuntimeReflectionService;
16+
use InvalidArgumentException;
1617
use Psr\Log\LoggerInterface;
1718
use ReflectionClass;
1819
use ReflectionProperty;
@@ -171,7 +172,7 @@ private function assertAttributesAreComplete(string $class): void
171172
}
172173

173174
if (0 === \count($this->translatedProperties)) {
174-
throw new RuntimeException('No translatable properties attributed with #[Polyglot\Translatable] were found');
175+
throw new RuntimeException('No translatable properties attributed with #[Polyglot\Translatable] (at the property level) or #[Polyglot\TranslatedProperty] (at the class level) were found');
175176
}
176177

177178
if (null === $this->primaryLocale) {
@@ -187,30 +188,44 @@ private function findTranslatedProperties(ClassMetadata $cm, ClassMetadataFactor
187188

188189
$reflectionService = $classMetadataFactory->getReflectionService();
189190
$translationClassMetadata = $classMetadataFactory->getMetadataFor($this->translationClass->getName());
191+
$reflectionClass = $cm->getReflectionClass();
190192

191-
/* Iterate all properties of the class, not only those mapped by Doctrine */
192-
foreach ($cm->getReflectionClass()->getProperties() as $reflectionProperty) {
193-
$propertyName = $reflectionProperty->name;
193+
/*
194+
Collect all (propertyName => translationFieldname) candidates from both sources.
195+
Using propertyName as key ensures deduplication when both sources declare the same property.
196+
*/
197+
$candidates = []; // propertyName => translationFieldname|null
194198

195-
/*
196-
If the property is inherited from a parent class, and our parent entity class
197-
already contains that declaration, we need not include it.
198-
*/
199-
$declaringClass = $reflectionProperty->getDeclaringClass()->name;
200-
if ($declaringClass !== $cm->name && $cm->parentClasses && is_a($cm->parentClasses[0], $declaringClass, true)) {
199+
/* Property-level #[Translatable] attributes */
200+
foreach ($reflectionClass->getProperties() as $reflectionProperty) {
201+
$attributes = $reflectionProperty->getAttributes(Attribute\Translatable::class);
202+
if (!$attributes || $this->isDeclaredByParentEntity($reflectionProperty, $cm)) {
201203
continue;
202204
}
203205

204-
$attributes = $reflectionProperty->getAttributes(Attribute\Translatable::class);
206+
$candidates[$reflectionProperty->name] = $attributes[0]->newInstance()->getTranslationFieldname();
207+
}
208+
209+
/* Class-level #[TranslatedProperty] attributes */
210+
foreach ($reflectionClass->getAttributes(Attribute\TranslatedProperty::class) as $classAttribute) {
211+
$attribute = $classAttribute->newInstance();
212+
$propertyName = $attribute->getPropertyName();
213+
214+
if (!$reflectionClass->hasProperty($propertyName)) {
215+
throw new InvalidArgumentException(\sprintf('Property "%s" not found in class "%s" (declared via #[TranslatedProperty]).', $propertyName, $cm->name));
216+
}
205217

206-
if (!$attributes) {
218+
if ($this->isDeclaredByParentEntity($reflectionClass->getProperty($propertyName), $cm)) {
207219
continue;
208220
}
209221

210-
$attribute = $attributes[0]->newInstance();
222+
$candidates[$propertyName] = $attribute->getTranslationFieldname();
223+
}
224+
225+
/* Register all collected candidates */
226+
foreach ($candidates as $propertyName => $translationFieldname) {
211227
$this->translatedProperties[$propertyName] = $reflectionService->getAccessibleProperty($cm->name, $propertyName);
212-
$translationFieldname = $attribute->getTranslationFieldname() ?: $propertyName;
213-
$this->translationFieldMapping[$propertyName] = $reflectionService->getAccessibleProperty($translationClassMetadata->name, $translationFieldname);
228+
$this->translationFieldMapping[$propertyName] = $reflectionService->getAccessibleProperty($translationClassMetadata->name, $translationFieldname ?: $propertyName);
214229
}
215230
}
216231

@@ -250,6 +265,17 @@ private function findPrimaryLocale(ClassMetadata $cm): void
250265
}
251266
}
252267

268+
/*
269+
Returns true if the property is declared in a parent class that is already covered
270+
by our parent entity's metadata, so we need not include it again.
271+
*/
272+
private function isDeclaredByParentEntity(ReflectionProperty $property, ClassMetadata $cm): bool
273+
{
274+
$declaringClass = $property->getDeclaringClass()->name;
275+
276+
return $declaringClass !== $cm->name && $cm->parentClasses && is_a($cm->parentClasses[0], $declaringClass, true);
277+
}
278+
253279
private function parseTranslationsEntity(ClassMetadata $cm): void
254280
{
255281
foreach ($cm->fieldMappings as $fieldName => $mapping) {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance;
4+
5+
use Doctrine\ORM\Mapping as ORM;
6+
use Webfactory\Bundle\PolyglotBundle\TranslatableInterface;
7+
8+
/**
9+
* A mapped superclass that carries a translatable property without any Polyglot
10+
* configuration. This way, subclasses can decide on their own whether they want
11+
* to use Polyglot or not.
12+
*/
13+
#[ORM\MappedSuperclass]
14+
abstract class EntityInheritance_MappedSuperclass
15+
{
16+
#[ORM\Id]
17+
#[ORM\GeneratedValue]
18+
#[ORM\Column(type: 'integer')]
19+
private ?int $id = null;
20+
21+
#[ORM\Column(type: 'string', nullable: true)]
22+
protected TranslatableInterface|string|null $text = null;
23+
24+
public function getId(): ?int
25+
{
26+
return $this->id;
27+
}
28+
29+
public function getText(): TranslatableInterface|string|null
30+
{
31+
return $this->text;
32+
}
33+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance;
4+
5+
use Doctrine\Common\Collections\ArrayCollection;
6+
use Doctrine\Common\Collections\Collection;
7+
use Doctrine\ORM\Mapping as ORM;
8+
use Webfactory\Bundle\PolyglotBundle\Attribute as Polyglot;
9+
use Webfactory\Bundle\PolyglotBundle\TranslatableInterface;
10+
11+
/**
12+
* An entity extending a mapped superclass, adding Polyglot support. The full Polyglot
13+
* configuration — including marking the inherited property for translation via
14+
* the class-level #[TranslatedProperty] attribute — lives here.
15+
*/
16+
#[Polyglot\Locale(primary: 'en_GB')]
17+
#[Polyglot\TranslatedProperty('text')]
18+
#[ORM\Entity]
19+
class EntityInheritance_MappedSuperclassEntity extends EntityInheritance_MappedSuperclass
20+
{
21+
#[Polyglot\TranslationCollection]
22+
#[ORM\OneToMany(targetEntity: EntityInheritance_MappedSuperclassEntityTranslation::class, mappedBy: 'entity')]
23+
private Collection $translations;
24+
25+
public function __construct()
26+
{
27+
$this->translations = new ArrayCollection();
28+
}
29+
30+
public function setText(TranslatableInterface $text): void
31+
{
32+
$this->text = $text;
33+
}
34+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance;
4+
5+
use Doctrine\ORM\Mapping as ORM;
6+
use Webfactory\Bundle\PolyglotBundle\Attribute as Polyglot;
7+
8+
#[ORM\Entity]
9+
class EntityInheritance_MappedSuperclassEntityTranslation
10+
{
11+
#[ORM\Id]
12+
#[ORM\GeneratedValue]
13+
#[ORM\Column(type: 'integer')]
14+
private ?int $id = null;
15+
16+
#[Polyglot\Locale]
17+
#[ORM\Column]
18+
private string $locale;
19+
20+
#[ORM\ManyToOne(inversedBy: 'translations')]
21+
private EntityInheritance_MappedSuperclassEntity $entity;
22+
23+
#[ORM\Column]
24+
private string $text;
25+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace Webfactory\Bundle\PolyglotBundle\Tests\Functional;
4+
5+
use Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance\EntityInheritance_MappedSuperclass;
6+
use Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance\EntityInheritance_MappedSuperclassEntity;
7+
use Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance\EntityInheritance_MappedSuperclassEntityTranslation;
8+
use Webfactory\Bundle\PolyglotBundle\Translatable;
9+
10+
/**
11+
* This tests that a property inherited from a MappedSuperclass can be declared
12+
* as translatable via the class-level #[TranslatedProperty] attribute on the
13+
* concrete entity. This makes it possible to define base classes (mapped superclasses)
14+
* that leave it to extending Entity subclasses whether to use Polyglot or not.
15+
*/
16+
class MappedSuperclassInheritanceTest extends DatabaseFunctionalTestCase
17+
{
18+
protected function setUp(): void
19+
{
20+
parent::setUp();
21+
22+
self::setupSchema([
23+
EntityInheritance_MappedSuperclass::class,
24+
EntityInheritance_MappedSuperclassEntity::class,
25+
EntityInheritance_MappedSuperclassEntityTranslation::class,
26+
]);
27+
}
28+
29+
public function testPersistAndReloadEntity(): void
30+
{
31+
$entity = new EntityInheritance_MappedSuperclassEntity();
32+
$t = new Translatable('base text');
33+
$t->setTranslation('Basistext', 'de_DE');
34+
$entity->setText($t);
35+
36+
self::import([$entity]);
37+
38+
$loaded = $this->entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId());
39+
40+
self::assertSame('base text', $loaded->getText()->translate('en_GB'));
41+
self::assertSame('Basistext', $loaded->getText()->translate('de_DE'));
42+
}
43+
44+
public function testAddTranslation(): void
45+
{
46+
$entityManager = $this->entityManager;
47+
48+
$entity = new EntityInheritance_MappedSuperclassEntity();
49+
$entity->setText(new Translatable('base text'));
50+
self::import([$entity]);
51+
52+
$loaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId());
53+
$loaded->getText()->setTranslation('Basistext', 'de_DE');
54+
$entityManager->flush();
55+
56+
$entityManager->clear();
57+
$reloaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId());
58+
59+
self::assertSame('base text', $reloaded->getText()->translate('en_GB'));
60+
self::assertSame('Basistext', $reloaded->getText()->translate('de_DE'));
61+
}
62+
63+
public function testUpdateTranslations(): void
64+
{
65+
$entityManager = $this->entityManager;
66+
67+
$entity = new EntityInheritance_MappedSuperclassEntity();
68+
$t = new Translatable('old text');
69+
$t->setTranslation('alter Text', 'de_DE');
70+
$entity->setText($t);
71+
self::import([$entity]);
72+
73+
$loaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId());
74+
$loaded->getText()->setTranslation('new text');
75+
$loaded->getText()->setTranslation('neuer Text', 'de_DE');
76+
$entityManager->flush();
77+
78+
$entityManager->clear();
79+
$reloaded = $entityManager->find(EntityInheritance_MappedSuperclassEntity::class, $entity->getId());
80+
81+
self::assertSame('new text', $reloaded->getText()->translate('en_GB'));
82+
self::assertSame('neuer Text', $reloaded->getText()->translate('de_DE'));
83+
}
84+
}

0 commit comments

Comments
 (0)