Skip to content

Commit 6357d2e

Browse files
committed
Add class-level #[TranslatedProperty] configuration option
1 parent 0b71665 commit 6357d2e

6 files changed

Lines changed: 245 additions & 15 deletions
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: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ private function assertAttributesAreComplete(string $class): void
171171
}
172172

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

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

188188
$reflectionService = $classMetadataFactory->getReflectionService();
189189
$translationClassMetadata = $classMetadataFactory->getMetadataFor($this->translationClass->getName());
190+
$reflectionClass = $cm->getReflectionClass();
190191

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

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)) {
198+
/* Property-level #[Translatable] attributes */
199+
foreach ($reflectionClass->getProperties() as $reflectionProperty) {
200+
$attributes = $reflectionProperty->getAttributes(Attribute\Translatable::class);
201+
if (!$attributes || $this->isDeclaredByParentEntity($reflectionProperty, $cm)) {
201202
continue;
202203
}
203204

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

206-
if (!$attributes) {
217+
if ($this->isDeclaredByParentEntity($reflectionClass->getProperty($propertyName), $cm)) {
207218
continue;
208219
}
209220

210-
$attribute = $attributes[0]->newInstance();
221+
$candidates[$propertyName] = $attribute->getTranslationFieldname();
222+
}
223+
224+
/* Register all collected candidates */
225+
foreach ($candidates as $propertyName => $translationFieldname) {
211226
$this->translatedProperties[$propertyName] = $reflectionService->getAccessibleProperty($cm->name, $propertyName);
212-
$translationFieldname = $attribute->getTranslationFieldname() ?: $propertyName;
213-
$this->translationFieldMapping[$propertyName] = $reflectionService->getAccessibleProperty($translationClassMetadata->name, $translationFieldname);
227+
$this->translationFieldMapping[$propertyName] = $reflectionService->getAccessibleProperty($translationClassMetadata->name, $translationFieldname ?: $propertyName);
214228
}
215229
}
216230

@@ -250,6 +264,17 @@ private function findPrimaryLocale(ClassMetadata $cm): void
250264
}
251265
}
252266

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

0 commit comments

Comments
 (0)