Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/Attribute/TranslatedProperty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

/*
* (c) webfactory GmbH <info@webfactory.de>
*
* 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;
}
}
56 changes: 41 additions & 15 deletions src/Doctrine/TranslatableClassMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance;

use Doctrine\ORM\Mapping as ORM;
use Webfactory\Bundle\PolyglotBundle\TranslatableInterface;

/**
* A mapped superclass that carries a translatable property without any Polyglot
* configuration. This way, subclasses can decide on their own whether they want
* to use Polyglot or not.
*/
#[ORM\MappedSuperclass]
abstract class EntityInheritance_MappedSuperclass
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;

#[ORM\Column(type: 'string', nullable: true)]
protected TranslatableInterface|string|null $text = null;

public function getId(): ?int
{
return $this->id;
}

public function getText(): TranslatableInterface|string|null
{
return $this->text;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Webfactory\Bundle\PolyglotBundle\Attribute as Polyglot;
use Webfactory\Bundle\PolyglotBundle\TranslatableInterface;

/**
* An entity extending a mapped superclass, adding Polyglot support. The full Polyglot
* configuration — including marking the inherited property for translation via
* the class-level #[TranslatedProperty] attribute — lives here.
*/
#[Polyglot\Locale(primary: 'en_GB')]
#[Polyglot\TranslatedProperty('text')]
#[ORM\Entity]
class EntityInheritance_MappedSuperclassEntity extends EntityInheritance_MappedSuperclass
{
#[Polyglot\TranslationCollection]
#[ORM\OneToMany(targetEntity: EntityInheritance_MappedSuperclassEntityTranslation::class, mappedBy: 'entity')]
private Collection $translations;

public function __construct()
{
$this->translations = new ArrayCollection();
}

public function setText(TranslatableInterface $text): void
{
$this->text = $text;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance;

use Doctrine\ORM\Mapping as ORM;
use Webfactory\Bundle\PolyglotBundle\Attribute as Polyglot;

#[ORM\Entity]
class EntityInheritance_MappedSuperclassEntityTranslation
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;

#[Polyglot\Locale]
#[ORM\Column]
private string $locale;

#[ORM\ManyToOne(inversedBy: 'translations')]
private EntityInheritance_MappedSuperclassEntity $entity;

#[ORM\Column]
private string $text;
}
84 changes: 84 additions & 0 deletions tests/Functional/MappedSuperclassInheritanceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace Webfactory\Bundle\PolyglotBundle\Tests\Functional;

use Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance\EntityInheritance_MappedSuperclass;
use Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance\EntityInheritance_MappedSuperclassEntity;
use Webfactory\Bundle\PolyglotBundle\Tests\Fixtures\Entity\EntityInheritance\EntityInheritance_MappedSuperclassEntityTranslation;
use Webfactory\Bundle\PolyglotBundle\Translatable;

/**
* This tests that a property inherited from a MappedSuperclass can be declared
* as translatable via the class-level #[TranslatedProperty] attribute on the
* concrete entity. This makes it possible to define base classes (mapped superclasses)
* that leave it to extending Entity subclasses whether to use Polyglot or not.
*/
class MappedSuperclassInheritanceTest extends DatabaseFunctionalTestCase
{
protected function setUp(): void
{
parent::setUp();

self::setupSchema([
EntityInheritance_MappedSuperclass::class,
EntityInheritance_MappedSuperclassEntity::class,
EntityInheritance_MappedSuperclassEntityTranslation::class,
]);
}

public function testPersistAndReloadEntity(): void
{
$entity = new EntityInheritance_MappedSuperclassEntity();
$t = new Translatable('base text');
$t->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'));
}
}
Loading