From e93b16152f924010dc817eed0ca70a48e59c1ce6 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Sat, 6 Jan 2024 12:37:18 +0100 Subject: [PATCH 1/3] Improve and simplify integration with ORM internals --- composer.json | 2 +- src/Doctrine/PersistentTranslatable.php | 195 ++++++------- src/Doctrine/PolyglotListener.php | 109 ++++---- .../SerializedTranslatableClassMetadata.php | 1 + src/Doctrine/TranslatableClassMetadata.php | 212 +++++++------- src/TranslatableInterface.php | 2 +- tests/Doctrine/PersistentTranslatableTest.php | 48 ++-- .../TranslatableClassMetadataTest.php | 21 +- tests/Functional/EntityInheritanceTest.php | 261 ++++++++++++++++++ tests/Functional/FunctionalTestBase.php | 28 ++ tests/{ => Functional}/IntegrationTest.php | 75 +++-- ...ranslationPropertyNamedDifferentlyTest.php | 164 +++++++++++ tests/Functional/UndeclaredBaseClassTest.php | 175 ++++++++++++ tests/TestEntity.php | 2 +- 14 files changed, 960 insertions(+), 335 deletions(-) create mode 100644 tests/Functional/EntityInheritanceTest.php create mode 100644 tests/Functional/FunctionalTestBase.php rename tests/{ => Functional}/IntegrationTest.php (66%) create mode 100644 tests/Functional/TranslationPropertyNamedDifferentlyTest.php create mode 100644 tests/Functional/UndeclaredBaseClassTest.php diff --git a/composer.json b/composer.json index 2dae76d..0efdd5d 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "php": "8.1.*|8.2.*|8.3.*", "doctrine/annotations": "^1.12", "doctrine/collections": "^1.0", - "doctrine/orm": "^2.2", + "doctrine/orm": "^2.10", "doctrine/persistence": "^1.3.8 | ^2.1", "psr/log": "^1.0", "symfony/config": "^5.4|^6.4|^7.0", diff --git a/src/Doctrine/PersistentTranslatable.php b/src/Doctrine/PersistentTranslatable.php index 1e30004..05fddbb 100644 --- a/src/Doctrine/PersistentTranslatable.php +++ b/src/Doctrine/PersistentTranslatable.php @@ -10,122 +10,136 @@ namespace Webfactory\Bundle\PolyglotBundle\Doctrine; use Doctrine\Common\Collections\Criteria; +use Doctrine\Common\Collections\Selectable; +use Doctrine\ORM\UnitOfWork; use Exception; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use ReflectionClass; use ReflectionProperty; +use Throwable; use Webfactory\Bundle\PolyglotBundle\Exception\TranslationException; use Webfactory\Bundle\PolyglotBundle\Locale\DefaultLocaleProvider; +use Webfactory\Bundle\PolyglotBundle\Translatable; use Webfactory\Bundle\PolyglotBundle\TranslatableInterface; /** - * Eine TranslationProxy-Implementierung für eine Entität, die - * bereits unter Verwaltung des EntityManagers steht. + * This class implements `TranslatableInterface` for entities that are managed by + * the entity manager. PolyglotListener will replace `Translatable` instances with + * instances of this class as soon as a new entity is passed to EntityManager::persist(). */ final class PersistentTranslatable implements TranslatableInterface { /** - * Cache für die Übersetzungen, indiziert nach Entity-OID und Locale. Ist static, damit ihn sich verschiedene Proxies (für die gleiche Entität, aber unterschiedliche Felder) teilen können. + * Cache to speed up accessing translated values, indexed by entity class, entity OID and locale. + * This is static so that it can be shared by multiple PersistentTranslatable instances that + * operate for the same entity instance, but different fields. * - * @var array> + * @var array>> */ private static array $_translations = []; /** - * Die Entität, in der sich dieser Proxy befindet (für die er Übersetzungen verwaltet). + * Object id for $this->entity. */ - private object $entity; + private int $oid; /** - * Der einzigartige Hash für die verwaltete Entität. - */ - private string $oid; - - /** - * Sprache, die in der Entität direkt abgelegt ist ("originärer" Content). - */ - private ?string $primaryLocale; - - /** - * Der Wert in der primary locale (der Wert in der Entität, den der Proxy ersetzt hat). + * The "primary" (untranslated) value for the property covered by this Translatable. */ private mixed $primaryValue; /** - * Provider, über den der Proxy die Locale erhält, in der Werte zurückgeben soll, wenn keine andere Locale explizit gewünscht wird. + * Whether original entity data was loaded by the ORM. */ - private DefaultLocaleProvider $defaultLocaleProvider; + private bool $hasOriginalEntityData; /** - * ReflectionProperty für die Eigenschaft der Translation-Klasse, die den übersetzten Wert hält. + * The original field value loaded by the ORM. */ - private ReflectionProperty $translatedProperty; + private mixed $originalEntityData; - /** - * ReflectionProperty für die Eigenschaft der Haupt-Klasse, in der die Übersetzungen als Doctrine Collection abgelegt sind. - */ - private ReflectionProperty $translationCollection; + private LoggerInterface $logger; /** - * ReflectionClass für die Klasse, die die Übersetzungen aufnimmt. + * @param UnitOfWork $unitOfWork The UoW managing the entity that contains this PersistentTranslatable + * @param class-string $class The class of the entity containing this PersistentTranslatable instance + * @param object $entity The entity containing this PersistentTranslatable instance + * @param string $primaryLocale The locale for which the translated value will be persisted in the "main" entity + * @param DefaultLocaleProvider $defaultLocaleProvider DefaultLocaleProvider that provides the locale to use when no explicit locale is passed to e. g. translate() + * @param ReflectionProperty $translationProperty ReflectionProperty pointing to the field in the translations class that holds the translated value to use + * @param ReflectionProperty $translationCollection ReflectionProperty pointing to the collection in the main class that holds translation instances + * @param ReflectionClass $translationClass ReflectionClass for the class holding translated values + * @param ReflectionProperty $localeField ReflectionProperty pointing to the field in the translations class that holds a translation's locale + * @param ReflectionProperty $translationMapping ReflectionProperty pointing to the field in the translations class that refers back to the main entity (the owning side of the one-to-many translations collection). + * @param ReflectionProperty $translatedProperty ReflectionProperty pointing to the field in the main entity where this PersistentTranslatable instance will be used */ - private ReflectionClass $translationClass; + public function __construct( + private readonly UnitOfWork $unitOfWork, + private readonly string $class, + private readonly object $entity, + private readonly string $primaryLocale, + private readonly DefaultLocaleProvider $defaultLocaleProvider, + private readonly ReflectionProperty $translationProperty, + private readonly ReflectionProperty $translationCollection, + private readonly ReflectionClass $translationClass, + private readonly ReflectionProperty $localeField, + private readonly ReflectionProperty $translationMapping, + private readonly ReflectionProperty $translatedProperty, + LoggerInterface $logger = null, + ) { + $this->oid = spl_object_id($entity); + $this->logger = $logger ?? new NullLogger(); - /** - * Das Feld in der Übersetzungs-Klasse, in dem die Locale einer Übersetzung abgelegt ist. - */ - private ReflectionProperty $localeField; + $data = $this->unitOfWork->getOriginalEntityData($entity); - /** - * Das Feld in der Übersetzungs-Klasse, in Many-to-one-Beziehung zur Entität abgelegt ist. - */ - private ReflectionProperty $translationMapping; + if ($data) { + $fieldName = $this->translatedProperty->getName(); + $this->hasOriginalEntityData = true; + $this->originalEntityData = $data[$fieldName]; - private LoggerInterface $logger; + // Set $this as the "original entity data", so Doctrine ORM + // change detection will not treat this new value as a relevant change + $this->unitOfWork->setOriginalEntityProperty($this->oid, $fieldName, $this); + } else { + $this->hasOriginalEntityData = false; + } - /** - * Sammelt neu hinzugefügte Übersetzungen, damit wir sie explizit speichern können, wenn ein - * Objekt im ORM abgelegt wird. - */ - private array $addedTranslations = []; + $currentValue = $this->translatedProperty->getValue($this->entity); - public function __construct( - object $entity, - ?string $primaryLocale, - DefaultLocaleProvider $defaultLocaleProvider, - ReflectionProperty $translatedProperty, - ReflectionProperty $translationCollection, - ReflectionClass $translationClass, - ReflectionProperty $localeField, - ReflectionProperty $translationMapping, - LoggerInterface $logger = null - ) { - $this->entity = $entity; - $this->oid = spl_object_hash($entity); - $this->primaryLocale = $primaryLocale; - $this->defaultLocaleProvider = $defaultLocaleProvider; - $this->translatedProperty = $translatedProperty; - $this->translationCollection = $translationCollection; - $this->translationClass = $translationClass; - $this->localeField = $localeField; - $this->translationMapping = $translationMapping; - $this->logger = $logger ?? new NullLogger(); + if ($currentValue instanceof Translatable) { + $currentValue->copy($this); + } else { + $this->primaryValue = $currentValue; + } + + $this->translatedProperty->setValue($this->entity, $this); } public function setPrimaryValue(mixed $value): void { $this->primaryValue = $value; - } - public function getPrimaryValue(): mixed - { - return $this->primaryValue; + if (!$this->hasOriginalEntityData) { + return; + } + + $fieldName = $this->translatedProperty->getName(); + + if ($value !== $this->originalEntityData) { + // Reset original entity data for the property where this PersistentTranslatable instance + // is being used. This way, on changeset computation in the ORM, the original data will mismatch + // the current value (which is $this object!). This will make $this->entity show up in the list + // of entity updates in the UoW. + $this->unitOfWork->setOriginalEntityProperty($this->oid, $fieldName, $this->originalEntityData); + } else { + $this->unitOfWork->setOriginalEntityProperty($this->oid, $fieldName, $this); + } } private function getTranslationEntity(string $locale): ?object { - if (false === $this->isTranslationCached($locale)) { + if (!$this->isTranslationCached($locale)) { $this->cacheTranslation($locale); } @@ -142,8 +156,8 @@ private function createTranslationEntity(string $locale): object $this->translationMapping->setValue($entity, $this->entity); $this->translationCollection->getValue($this->entity)->add($entity); - self::$_translations[$this->oid][$locale] = $entity; - $this->addedTranslations[] = $entity; + self::$_translations[$this->class][$this->oid][$locale] = $entity; + $this->unitOfWork->persist($entity); return $entity; } @@ -152,13 +166,13 @@ public function setTranslation(mixed $value, string $locale = null): void { $locale = $locale ?: $this->getDefaultLocale(); if ($locale === $this->primaryLocale) { - $this->primaryValue = $value; + $this->setPrimaryValue($value); } else { $entity = $this->getTranslationEntity($locale); if (!$entity) { $entity = $this->createTranslationEntity($locale); } - $this->translatedProperty->setValue($entity, $value); + $this->translationProperty->setValue($entity, $value); } } @@ -169,12 +183,12 @@ public function translate(string $locale = null): mixed { $locale = $locale ?: $this->getDefaultLocale(); try { - if ($locale == $this->primaryLocale) { + if ($locale === $this->primaryLocale) { return $this->primaryValue; } if ($entity = $this->getTranslationEntity($locale)) { - $translated = $this->translatedProperty->getValue($entity); + $translated = $this->translationProperty->getValue($entity); if (null !== $translated) { return $translated; } @@ -185,7 +199,7 @@ public function translate(string $locale = null): mixed $message = sprintf( 'Cannot translate property %s::%s into locale %s', \get_class($this->entity), - $this->translatedProperty->getName(), + $this->translationProperty->getName(), $locale ); throw new TranslationException($message, $e); @@ -200,33 +214,20 @@ public function isTranslatedInto(string $locale): bool $entity = $this->getTranslationEntity($locale); - return $entity && null !== $this->translatedProperty->getValue($entity); + return $entity && null !== $this->translationProperty->getValue($entity); } public function __toString(): string { try { return (string) $this->translate(); - } catch (Exception $e) { + } catch (Throwable $e) { $this->logger->error($this->stringifyException($e)); return ''; } } - /** - * Clears the list of newly added translation entities, returning the old value. - * - * @return list The list of new entites holding translations (exact class depends on the entity containing this proxy), before being reset. - */ - public function getAndResetNewTranslations(): array - { - $newTranslations = $this->addedTranslations; - $this->addedTranslations = []; - - return $newTranslations; - } - private function getDefaultLocale(): string { return $this->defaultLocaleProvider->getDefaultLocale(); @@ -234,27 +235,27 @@ private function getDefaultLocale(): string private function isTranslationCached(string $locale): bool { - return isset(self::$_translations[$this->oid][$locale]); + return isset(self::$_translations[$this->class][$this->oid][$locale]); } /** * The collection filtering API will issue a SQL query every time if the collection is not in memory; that is, it - * does not manage "partially initialized" collections. For this reason we cache the lookup results on our own - * (in-memory per-request) in a static member variable so they can be shared among all TranslationProxies. + * does not manage "partially initialized" collections. For this reason, we cache the lookup results on our own + * (in-memory per-request) in a static member variable, so they can be shared among all TranslationProxies. */ private function cacheTranslation(string $locale): void { - /* @var $translationsInAllLanguages \Doctrine\Common\Collections\Selectable */ + /** @var $translationsInAllLanguages Selectable */ $translationsInAllLanguages = $this->translationCollection->getValue($this->entity); $criteria = $this->createLocaleCriteria($locale); $translationsFilteredByLocale = $translationsInAllLanguages->matching($criteria); $translationInLocale = ($translationsFilteredByLocale->count() > 0) ? $translationsFilteredByLocale->first() : null; - self::$_translations[$this->oid][$locale] = $translationInLocale; + self::$_translations[$this->class][$this->oid][$locale] = $translationInLocale; } - private function createLocaleCriteria($locale): Criteria + private function createLocaleCriteria(string $locale): Criteria { return Criteria::create() ->where( @@ -264,10 +265,10 @@ private function createLocaleCriteria($locale): Criteria private function getCachedTranslation(string $locale): ?object { - return self::$_translations[$this->oid][$locale]; + return self::$_translations[$this->class][$this->oid][$locale]; } - private function stringifyException(Exception $e): string + private function stringifyException(Throwable $e): string { $exceptionAsString = ''; while (null !== $e) { diff --git a/src/Doctrine/PolyglotListener.php b/src/Doctrine/PolyglotListener.php index f1389cb..a98ef0a 100644 --- a/src/Doctrine/PolyglotListener.php +++ b/src/Doctrine/PolyglotListener.php @@ -10,99 +10,91 @@ namespace Webfactory\Bundle\PolyglotBundle\Doctrine; use Doctrine\Common\Annotations\Reader; +use Doctrine\Common\EventSubscriber; use Doctrine\ORM\EntityManager; -use Doctrine\ORM\Event\LifecycleEventArgs; -use Doctrine\ORM\Event\PostFlushEventArgs; -use Doctrine\ORM\Event\PreFlushEventArgs; -use Doctrine\ORM\Mapping\ClassMetadataInfo; +use Doctrine\Persistence\Event\LifecycleEventArgs; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use SplObjectStorage; use Webfactory\Bundle\PolyglotBundle\Locale\DefaultLocaleProvider; -final class PolyglotListener +final class PolyglotListener implements EventSubscriber { - public const CACHE_SALT = '$WebfactoryPolyglot'; + private const CACHE_SALT = '$WebfactoryPolyglot'; - private Reader $reader; + /** + * Field to cache TranslatableClassMetadata information for a class and all of its + * parent classes, indexed by the leaf (child) class. + * + * @var array> + */ + private array $translatableClassMetadatasByClass = []; /** - * @var array + * Field to cache TranslatableClassMetadata information by class name. + * + * @var array */ private array $translatedClasses = []; - private array $_proxiesStripped = []; - private DefaultLocaleProvider $defaultLocaleProvider; - - private SplObjectStorage $entitiesWithTranslations; - - private LoggerInterface $logger; - public function __construct( - Reader $annotationReader, - DefaultLocaleProvider $defaultLocaleProvider, - LoggerInterface $logger = null + private readonly Reader $annotationReader, + private readonly DefaultLocaleProvider $defaultLocaleProvider, + private readonly LoggerInterface $logger = null ?? new NullLogger(), ) { - $this->reader = $annotationReader; - $this->defaultLocaleProvider = $defaultLocaleProvider; - $this->logger = $logger ?? new NullLogger(); + } - $this->entitiesWithTranslations = new SplObjectStorage(); + public function getSubscribedEvents(): array + { + return [ + 'prePersist', + 'postLoad', + ]; } public function postLoad(LifecycleEventArgs $event): void { // Called when the entity has been hydrated - $entity = $event->getEntity(); + $objectManager = $event->getObjectManager(); + $object = $event->getObject(); - if ($tm = $this->getTranslationMetadataForLifecycleEvent($event)) { - $this->entitiesWithTranslations->attach($entity, $tm); - $tm->injectProxies($entity, $this->defaultLocaleProvider); + foreach ($this->getTranslationMetadatas($object, $objectManager) as $tm) { + $tm->injectPersistentTranslatables($object, $objectManager, $this->defaultLocaleProvider); } } public function prePersist(LifecycleEventArgs $event): void { // Called before a new entity is persisted for the first time - $entity = $event->getEntity(); - - if ($tm = $this->getTranslationMetadataForLifecycleEvent($event)) { - $tm->manageTranslations($entity, $this->defaultLocaleProvider); - $this->entitiesWithTranslations->attach($entity, $tm); - } - } + $objectManager = $event->getObjectManager(); + $object = $event->getObject(); - public function preFlush(PreFlushEventArgs $event): void - { - $entityManager = $event->getEntityManager(); - // Called before changes are flushed out to the database - even before the change sets are computed - foreach ($this->entitiesWithTranslations as $entity) { - /** @var TranslatableClassMetadata $translationMetadata */ - $translationMetadata = $this->entitiesWithTranslations[$entity]; - $translationMetadata->preFlush($entity, $entityManager); + foreach ($this->getTranslationMetadatas($object, $objectManager) as $tm) { + $tm->injectPersistentTranslatables($object, $objectManager, $this->defaultLocaleProvider); } } - public function postFlush(PostFlushEventArgs $event): void + /** + * @return list + */ + private function getTranslationMetadatas(object $entity, EntityManager $em): array { - // The postFlush event occurs at the end of a flush operation. - foreach ($this->entitiesWithTranslations as $entity) { - $translationMetadata = $this->entitiesWithTranslations[$entity]; - $translationMetadata->injectProxies($entity, $this->defaultLocaleProvider); - } - } + $class = $entity::class; - private function getTranslationMetadataForLifecycleEvent(LifecycleEventArgs $event): ?TranslatableClassMetadata - { - $entity = $event->getEntity(); - $em = $event->getEntityManager(); + if (!isset($this->translatableClassMetadatasByClass[$class])) { + $this->translatableClassMetadatasByClass[$class] = []; + $classMetadata = $em->getClassMetadata($class); - $className = $entity::class; + foreach (array_merge([$classMetadata->name], $classMetadata->parentClasses) as $className) { + if ($tm = $this->loadTranslationMetadataForClass($className, $em)) { + $this->translatableClassMetadatasByClass[$class][] = $tm; + } + } + } - return $this->getTranslationMetadata($className, $em); + return $this->translatableClassMetadatasByClass[$class]; } - private function getTranslationMetadata($className, EntityManager $em): ?TranslatableClassMetadata + private function loadTranslationMetadataForClass($className, EntityManager $em): ?TranslatableClassMetadata { // In memory cache if (isset($this->translatedClasses[$className])) { @@ -129,9 +121,8 @@ private function getTranslationMetadata($className, EntityManager $em): ?Transla } // Load/parse - /* @var $metadataInfo ClassMetadataInfo */ - $metadataInfo = $metadataFactory->getMetadataFor($className); - $meta = TranslatableClassMetadata::parseFromClassMetadata($metadataInfo, $this->reader); + $meta = TranslatableClassMetadata::parseFromClass($className, $this->annotationReader, $metadataFactory); + if (null !== $meta) { $meta->setLogger($this->logger); $this->translatedClasses[$className] = $meta; diff --git a/src/Doctrine/SerializedTranslatableClassMetadata.php b/src/Doctrine/SerializedTranslatableClassMetadata.php index 18f25a3..fc3fdbf 100644 --- a/src/Doctrine/SerializedTranslatableClassMetadata.php +++ b/src/Doctrine/SerializedTranslatableClassMetadata.php @@ -11,6 +11,7 @@ final class SerializedTranslatableClassMetadata { + public string $class; public string $translationClass; /** diff --git a/src/Doctrine/TranslatableClassMetadata.php b/src/Doctrine/TranslatableClassMetadata.php index d6a3f4a..1973177 100644 --- a/src/Doctrine/TranslatableClassMetadata.php +++ b/src/Doctrine/TranslatableClassMetadata.php @@ -12,8 +12,8 @@ use Doctrine\Common\Annotations\Reader; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManager; +use Doctrine\ORM\Mapping\ClassMetadataFactory; use Doctrine\ORM\Mapping\ClassMetadataInfo; -use Doctrine\Persistence\Mapping\ClassMetadata; use Psr\Log\LoggerInterface; use ReflectionClass; use ReflectionProperty; @@ -61,28 +61,42 @@ final class TranslatableClassMetadata private ?ReflectionProperty $translationLocaleProperty = null; /** - * @var ReflectionClass Die Übersetzungs-Klasse. + * Die Übersetzungs-Klasse. */ private ?ReflectionClass $translationClass = null; /** - * @var string Die Locale der Werte in der Haupt-Klasse. + * Die Locale der Werte in der Haupt-Klasse. */ private ?string $primaryLocale = null; private ?LoggerInterface $logger = null; - public static function parseFromClassMetadata(ClassMetadataInfo $cm, Reader $reader): ?self + /** + * @param class-string $class The FQCN for the entity class whose translatable fields are described by this + * TranslatableClassMetadata instance. If the class has base entity classes (or mapped + * superclasses), a separate instance of TranslatableClassMetadata will be used for + * their fields. + */ + private function __construct( + private readonly string $class + ) { + } + + public static function parseFromClass(string $class, Reader $reader, ClassMetadataFactory $classMetadataFactory): ?self { - $tm = new self(); - $tm->findPrimaryLocale($reader, $cm); - $tm->findTranslationsCollection($reader, $cm); - $tm->findTranslatedProperties($reader, $cm); + /** @var ClassMetadataInfo $cm */ + $cm = $classMetadataFactory->getMetadataFor($class); + + $tm = new static($class); + $tm->findPrimaryLocale($cm, $reader); + $tm->findTranslationsCollection($cm, $reader, $classMetadataFactory); + $tm->findTranslatedProperties($cm, $reader, $classMetadataFactory); if ($tm->assertNoAnnotationsArePresent()) { return null; } - $tm->assertAnnotationsAreComplete(); + $tm->assertAnnotationsAreComplete($class); return $tm; } @@ -95,6 +109,7 @@ public function setLogger(LoggerInterface $logger = null): void public function sleep(): SerializedTranslatableClassMetadata { $sleep = new SerializedTranslatableClassMetadata(); + $sleep->class = $this->class; $sleep->primaryLocale = $this->primaryLocale; $sleep->translationClass = $this->translationClass->name; @@ -115,7 +130,7 @@ public function sleep(): SerializedTranslatableClassMetadata public static function wakeup(SerializedTranslatableClassMetadata $data): self { - $self = new self(); + $self = new self($data->class); $self->primaryLocale = $data->primaryLocale; $self->translationClass = new ReflectionClass($data->translationClass); @@ -139,10 +154,11 @@ private function assertNoAnnotationsArePresent(): bool return null === $this->translationClass && null === $this->translationLocaleProperty && null === $this->translationMappingProperty - && 0 === \count($this->translatedProperties); + && 0 === \count($this->translatedProperties) + && null === $this->primaryLocale; } - private function assertAnnotationsAreComplete(): void + private function assertAnnotationsAreComplete(string $class): void { if (null === $this->translationClass) { throw new RuntimeException('The annotation with the translation class name is missing or incorrect, e.g. @ORM\OneToMany(targetEntity="TestEntityTranslation", ...)'); @@ -159,97 +175,96 @@ private function assertAnnotationsAreComplete(): void if (0 === \count($this->translatedProperties)) { throw new RuntimeException('No translatable attributes annotated with @Polyglot\Translatable were found'); } + + if (null === $this->primaryLocale) { + throw new RuntimeException('A primary locale has to be set at the class level for '.$class); + } } - private function findTranslatedProperties(Reader $reader, ClassMetadata $classMetadata): void + private function findTranslatedProperties(ClassMetadataInfo $cm, Reader $reader, ClassMetadataFactory $classMetadataFactory): void { - if ($this->translationClass) { - foreach ($classMetadata->getReflectionClass()->getProperties() as $property) { - $annotation = $reader->getPropertyAnnotation( - $property, - Annotation\Translatable::class - ); - if (null !== $annotation) { - $fieldname = $property->getName(); - $property->setAccessible(true); - - $this->translatedProperties[$fieldname] = $property; - - $translationFieldname = $annotation->getTranslationFieldname() ?: $fieldname; - $translationField = $this->translationClass->getProperty($translationFieldname); - $translationField->setAccessible(true); - $this->translationFieldMapping[$fieldname] = $translationField; - } + if (!$this->translationClass) { + return; + } + + $translationClassMetadata = $classMetadataFactory->getMetadataFor($this->translationClass->getName()); + + foreach ($cm->fieldMappings as $fieldName => $mapping) { + if (isset($mapping['declared'])) { + // The association is inherited from a parent class + continue; + } + + $reflectionProperty = $cm->getReflectionClass()->getProperty($fieldName); + + $annotation = $reader->getPropertyAnnotation( + $reflectionProperty, + Annotation\Translatable::class + ); + + if ($annotation) { + $translationFieldname = $annotation->getTranslationFieldname() ?: $fieldName; + $translationFieldReflectionProperty = $translationClassMetadata->getReflectionProperty($translationFieldname); + + $this->translatedProperties[$fieldName] = $reflectionProperty; + $this->translationFieldMapping[$fieldName] = $translationFieldReflectionProperty; } } } - private function findTranslationsCollection(Reader $reader, ClassMetadataInfo $classMetadata): void + private function findTranslationsCollection(ClassMetadataInfo $cm, Reader $reader, ClassMetadataFactory $classMetadataFactory): void { - foreach ($classMetadata->getReflectionClass()->getProperties() as $property) { + foreach ($cm->associationMappings as $fieldName => $mapping) { + if (isset($mapping['declared'])) { + // The association is inherited from a parent class + continue; + } + $annotation = $reader->getPropertyAnnotation( - $property, + $cm->getReflectionProperty($fieldName), Annotation\TranslationCollection::class ); - if (null !== $annotation) { - $this->translationsCollectionProperty = $property; - $am = $classMetadata->getAssociationMapping($property->getName()); - $this->parseTranslationsEntity($reader, $am['targetEntity']); - $translationMappingProperty = $this->translationClass->getProperty($am['mappedBy']); - $this->translationMappingProperty = $translationMappingProperty; - break; + if ($annotation) { + $this->translationsCollectionProperty = $cm->getReflectionClass()->getProperty($fieldName); + + $translationEntityMetadata = $classMetadataFactory->getMetadataFor($mapping['targetEntity']); + $this->translationClass = $translationEntityMetadata->getReflectionClass(); + $this->translationMappingProperty = $translationEntityMetadata->getReflectionProperty($mapping['mappedBy']); + $this->parseTranslationsEntity($reader, $translationEntityMetadata); + + return; } } } - private function findPrimaryLocale(Reader $reader, ClassMetadata $classMetadata): void + private function findPrimaryLocale(ClassMetadataInfo $cm, Reader $reader): void { - $annotation = $reader->getClassAnnotation( - $classMetadata->getReflectionClass(), - Annotation\Locale::class - ); - if (null !== $annotation) { - $this->primaryLocale = $annotation->getPrimary(); + foreach (array_merge([$cm->name], $cm->parentClasses) as $class) { + $annotation = $reader->getClassAnnotation(new ReflectionClass($class), Annotation\Locale::class); + if (null !== $annotation) { + $this->primaryLocale = $annotation->getPrimary(); + + return; + } } } - private function parseTranslationsEntity(Reader $reader, $class): void + private function parseTranslationsEntity(Reader $reader, ClassMetadataInfo $cm): void { - $this->translationClass = new ReflectionClass($class); + foreach ($cm->fieldMappings as $fieldName => $mapping) { + $reflectionProperty = $cm->getReflectionProperty($fieldName); - foreach ($this->translationClass->getProperties() as $property) { $annotation = $reader->getPropertyAnnotation( - $property, + $reflectionProperty, Annotation\Locale::class ); - if (null !== $annotation) { - $property->setAccessible(true); - $this->translationLocaleProperty = $property; - } - } - } - public function preFlush(object $entity, EntityManager $entityManager): void - { - foreach ($this->translatedProperties as $property) { - $proxy = $property->getValue($entity); - - if ($proxy instanceof PersistentTranslatable) { - foreach ($proxy->getAndResetNewTranslations() as $translationEntity) { - $entityManager->persist($translationEntity); - } - $property->setValue($entity, $proxy->getPrimaryValue()); - } - } - } + if ($annotation) { + $this->translationLocaleProperty = $reflectionProperty; - public function injectProxies(object $entity, DefaultLocaleProvider $defaultLocaleProvider): void - { - foreach ($this->translatedProperties as $fieldname => $property) { - $proxy = $this->createProxy($entity, $fieldname, $defaultLocaleProvider); - $proxy->setPrimaryValue($property->getValue($entity)); - $property->setValue($entity, $proxy); + return; + } } } @@ -257,36 +272,23 @@ public function injectProxies(object $entity, DefaultLocaleProvider $defaultLoca * For a given entity, find all @Translatable fields that contain new (not yet persisted) * Translatable objects and replace those with PersistentTranslatable. */ - public function manageTranslations(object $entity, DefaultLocaleProvider $defaultLocaleProvider): void + public function injectPersistentTranslatables(object $entity, EntityManager $entityManager, DefaultLocaleProvider $defaultLocaleProvider): void { - foreach ($this->translatedProperties as $fieldname => $property) { - $translatableValue = $property->getValue($entity); - - if ($translatableValue instanceof Translatable) { - $newProxy = $this->createProxy($entity, $fieldname, $defaultLocaleProvider); - $translatableValue->copy($newProxy); - $property->setValue($entity, $newProxy); - } + foreach ($this->translatedProperties as $fieldName => $property) { + new PersistentTranslatable( + $entityManager->getUnitOfWork(), + $this->class, + $entity, + $this->primaryLocale, + $defaultLocaleProvider, + $this->translationFieldMapping[$fieldName], + $this->translationsCollectionProperty, + $this->translationClass, + $this->translationLocaleProperty, + $this->translationMappingProperty, + $property, + $this->logger + ); } } - - public function getTranslations(object $entity): Collection - { - return $this->translationsCollectionProperty->getValue($entity); - } - - private function createProxy($entity, $fieldname, DefaultLocaleProvider $defaultLocaleProvider): PersistentTranslatable - { - return new PersistentTranslatable( - $entity, - $this->primaryLocale, - $defaultLocaleProvider, - $this->translationFieldMapping[$fieldname], - $this->translationsCollectionProperty, - $this->translationClass, - $this->translationLocaleProperty, - $this->translationMappingProperty, - $this->logger - ); - } } diff --git a/src/TranslatableInterface.php b/src/TranslatableInterface.php index 8537186..d96c406 100644 --- a/src/TranslatableInterface.php +++ b/src/TranslatableInterface.php @@ -28,7 +28,7 @@ public function translate(string $locale = null): mixed; * * @param string|null $locale The target locale or null for the current locale. */ - public function setTranslation(mixed $value, string $locale = null); + public function setTranslation(mixed $value, string $locale = null): void; /** * Returns wether the text is translated into the target locale. diff --git a/tests/Doctrine/PersistentTranslatableTest.php b/tests/Doctrine/PersistentTranslatableTest.php index 4273903..04192c3 100644 --- a/tests/Doctrine/PersistentTranslatableTest.php +++ b/tests/Doctrine/PersistentTranslatableTest.php @@ -2,6 +2,7 @@ namespace Webfactory\Bundle\PolyglotBundle\Tests\Doctrine; +use Doctrine\ORM\UnitOfWork; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -12,10 +13,11 @@ use Webfactory\Bundle\PolyglotBundle\Doctrine\PersistentTranslatable; use Webfactory\Bundle\PolyglotBundle\Locale\DefaultLocaleProvider; use Webfactory\Bundle\PolyglotBundle\Tests\TestEntity; +use Webfactory\Bundle\PolyglotBundle\Tests\TestEntityTranslation; class PersistentTranslatableTest extends TestCase { - public function testToStringReturnsTranslatedMessage(): void + public function testToStringReturnsTranslatedMessage() { $entity = new TestEntity('foo'); $proxy = $this->createProxy($entity); @@ -29,7 +31,7 @@ public function testToStringReturnsTranslatedMessage(): void self::assertEquals('bar', $translation); } - public function testToStringReturnsStringIfExceptionOccurredAndNoLoggerIsAvailable(): void + public function testToStringReturnsStringIfExceptionOccurredAndNoLoggerIsAvailable() { $entity = new TestEntity('foo'); $proxy = $this->createProxy($entity); @@ -39,7 +41,7 @@ public function testToStringReturnsStringIfExceptionOccurredAndNoLoggerIsAvailab self::assertIsString($translation); } - public function testToStringReturnsStringIfExceptionOccurredAndLoggerIsAvailable(): void + public function testToStringReturnsStringIfExceptionOccurredAndLoggerIsAvailable() { $entity = new TestEntity('foo'); $proxy = $this->createProxy($entity, new NullLogger()); @@ -49,7 +51,7 @@ public function testToStringReturnsStringIfExceptionOccurredAndLoggerIsAvailable self::assertIsString($translation); } - public function testToStringLogsExceptionIfLoggerIsAvailable(): void + public function testToStringLogsExceptionIfLoggerIsAvailable() { $entity = new TestEntity('foo'); @@ -62,7 +64,7 @@ public function testToStringLogsExceptionIfLoggerIsAvailable(): void self::assertGreaterThan(0, \count($logger->cleanLogs()), 'Expected at least one log message'); } - public function testLoggedMessageContainsInformationAboutTranslatedProperty(): void + public function testLoggedMessageContainsInformationAboutTranslatedProperty() { $entity = new TestEntity('foo'); @@ -82,7 +84,7 @@ public function testLoggedMessageContainsInformationAboutTranslatedProperty(): v self::assertStringContainsString('de', $logMessage, 'Missing locale.'); } - public function testLoggedMessageContainsOriginalException(): void + public function testLoggedMessageContainsOriginalException() { $entity = new TestEntity('foo'); @@ -101,7 +103,7 @@ public function testLoggedMessageContainsOriginalException(): void } /** @test */ - public function isTranslatedInto_returns_true_for_primary_translation_if_set(): void + public function isTranslatedInto_returns_true_for_primary_translation_if_set() { $entity = new TestEntity('foo'); $proxy = $this->createProxy($entity); @@ -112,7 +114,7 @@ public function isTranslatedInto_returns_true_for_primary_translation_if_set(): } /** @test */ - public function isTranslatedInto_returns_true_for_translation_if_set(): void + public function isTranslatedInto_returns_true_for_translation_if_set() { $entity = new TestEntity('foo'); $proxy = $this->createProxy($entity); @@ -123,7 +125,7 @@ public function isTranslatedInto_returns_true_for_translation_if_set(): void } /** @test */ - public function isTranslatedInto_returns_false_if_primary_translation_is_empty(): void + public function isTranslatedInto_returns_false_if_primary_translation_is_empty() { $entity = new TestEntity('foo'); $proxy = $this->createProxy($entity); @@ -135,7 +137,7 @@ public function isTranslatedInto_returns_false_if_primary_translation_is_empty() } /** @test */ - public function isTranslatedInto_returns_false_if_translation_is_not_set(): void + public function isTranslatedInto_returns_false_if_translation_is_not_set() { $entity = new TestEntity('foo'); $proxy = $this->createProxy($entity); @@ -145,14 +147,20 @@ public function isTranslatedInto_returns_false_if_translation_is_not_set(): void self::assertFalse($proxy->isTranslatedInto('fr')); } - private function createProxy(TestEntity $entity, LoggerInterface $logger = null): PersistentTranslatable + /** + * @return PersistentTranslatable + */ + private function createProxy(TestEntity $entity, LoggerInterface $logger = null) { $localeProvider = new DefaultLocaleProvider(); $localeProvider->setDefaultLocale('de'); // We need a translation class without required constructor parameters. - $translationClass = 'Webfactory\Bundle\PolyglotBundle\Tests\TestEntityTranslation'; - $proxy = new PersistentTranslatable( + $translationClass = TestEntityTranslation::class; + + return new PersistentTranslatable( + $this->createMock(UnitOfWork::class), + TestEntity::class, $entity, 'en', $localeProvider, @@ -161,19 +169,27 @@ private function createProxy(TestEntity $entity, LoggerInterface $logger = null) new ReflectionClass($translationClass), new ReflectionProperty($translationClass, 'locale'), new ReflectionProperty($translationClass, 'entity'), + self::accessibleProperty(TestEntity::class, 'text'), $logger ); - - return $proxy; } - private function breakEntity(TestEntity $entity): void + private function breakEntity(TestEntity $entity) { $brokenCollection = $this->getMockBuilder('Doctrine\Common\Collections\ArrayCollection')->getMock(); $brokenCollection->expects($this->any()) ->method('matching') ->will($this->throwException(new RuntimeException('Cannot find translations'))); $property = new ReflectionProperty($entity, 'translations'); + $property->setAccessible(true); $property->setValue($entity, $brokenCollection); } + + private static function accessibleProperty(string $class, string $property): ReflectionProperty + { + $property = new ReflectionProperty($class, $property); + $property->setAccessible(true); + + return $property; + } } diff --git a/tests/Doctrine/TranslatableClassMetadataTest.php b/tests/Doctrine/TranslatableClassMetadataTest.php index 97254a6..d32f876 100644 --- a/tests/Doctrine/TranslatableClassMetadataTest.php +++ b/tests/Doctrine/TranslatableClassMetadataTest.php @@ -4,7 +4,6 @@ use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; use Webfactory\Bundle\PolyglotBundle\Doctrine\TranslatableClassMetadata; use Webfactory\Bundle\PolyglotBundle\Tests\TestEntity; use Webfactory\Bundle\PolyglotBundle\Tests\TestEntityTranslation; @@ -25,21 +24,15 @@ public function can_be_serialized_and_retrieved(): void self::assertEquals($metadata, $unserialized); } - private function createMetadata(LoggerInterface $logger = null): TranslatableClassMetadata + private function createMetadata(): TranslatableClassMetadata { $reader = new AnnotationReader(); - $infrastructure = new ORMInfrastructure( - [ - TestEntity::class, - TestEntityTranslation::class, - ] - ); - $metadata = $infrastructure->getEntityManager()->getClassMetadata(TestEntity::class); - $metadata = TranslatableClassMetadata::parseFromClassMetadata($metadata, $reader); - if (null !== $logger) { - $metadata->setLogger($logger); - } + $infrastructure = new ORMInfrastructure([ + TestEntity::class, + TestEntityTranslation::class, + ]); + $entityManager = $infrastructure->getEntityManager(); - return $metadata; + return TranslatableClassMetadata::parseFromClass(TestEntity::class, $reader, $entityManager->getMetadataFactory()); } } diff --git a/tests/Functional/EntityInheritanceTest.php b/tests/Functional/EntityInheritanceTest.php new file mode 100644 index 0000000..97ffbb3 --- /dev/null +++ b/tests/Functional/EntityInheritanceTest.php @@ -0,0 +1,261 @@ +setupOrmInfrastructure([ + EntityInheritance_BaseEntityClass::class, + EntityInheritance_BaseEntityClassTranslation::class, + EntityInheritance_ChildEntityClass::class, + EntityInheritance_ChildEntityClassTranslation::class, + ]); + } + + public function testPersistAndReloadEntity(): void + { + $entity = new EntityInheritance_ChildEntityClass(); + $t1 = new Translatable('base text'); + $t1->setTranslation('Basistext', 'de_DE'); + $entity->setText($t1); + + $t2 = new Translatable('extra text'); + $t2->setTranslation('Extratext', 'de_DE'); + $entity->setExtra($t2); + + $this->infrastructure->import($entity); + + $loaded = $this->infrastructure->getEntityManager()->find(EntityInheritance_ChildEntityClass::class, $entity->getId()); + + self::assertSame('Basistext', $loaded->getText()->translate('de_DE')); + self::assertSame('Extratext', $loaded->getExtraText()->translate('de_DE')); + self::assertSame('base text', $loaded->getText()->translate('en_GB')); + self::assertSame('extra text', $loaded->getExtraText()->translate('en_GB')); + } + + public function testAddTranslation(): void + { + $entityManager = $this->infrastructure->getEntityManager(); + $entity = new EntityInheritance_ChildEntityClass(); + $entity->setText(new Translatable('base text')); + $entity->setExtra(new Translatable('extra text')); + $this->infrastructure->import($entity); + + $loaded = $entityManager->find(EntityInheritance_ChildEntityClass::class, $entity->getId()); + $loaded->getText()->setTranslation('Basistext', 'de_DE'); + $loaded->getExtraText()->setTranslation('Extratext', 'de_DE'); + $entityManager->flush(); + + $entityManager->clear(); + $reloaded = $entityManager->find(EntityInheritance_ChildEntityClass::class, $entity->getId()); + + self::assertSame('Basistext', $reloaded->getText()->translate('de_DE')); + self::assertSame('Extratext', $reloaded->getExtraText()->translate('de_DE')); + self::assertSame('base text', $reloaded->getText()->translate('en_GB')); + self::assertSame('extra text', $reloaded->getExtraText()->translate('en_GB')); + } + + public function testUpdateTranslations(): void + { + $entityManager = $this->infrastructure->getEntityManager(); + + $entity = new EntityInheritance_ChildEntityClass(); + $t1 = new Translatable('old base text'); + $t1->setTranslation('alter Basistext', 'de_DE'); + $entity->setText($t1); + + $t2 = new Translatable('old extra text'); + $t2->setTranslation('alter Extratext', 'de_DE'); + $entity->setExtra($t2); + + $this->infrastructure->import($entity); + + $loaded = $entityManager->find(EntityInheritance_ChildEntityClass::class, $entity->getId()); + $loaded->getText()->setTranslation('new base text'); + $loaded->getText()->setTranslation('neuer Basistext', 'de_DE'); + $loaded->getExtraText()->setTranslation('new extra text'); + $loaded->getExtraText()->setTranslation('neuer Extratext', 'de_DE'); + $entityManager->flush(); + + $entityManager->clear(); + $reloaded = $entityManager->find(EntityInheritance_ChildEntityClass::class, $entity->getId()); + + self::assertSame('neuer Basistext', $reloaded->getText()->translate('de_DE')); + self::assertSame('neuer Extratext', $reloaded->getExtraText()->translate('de_DE')); + self::assertSame('new base text', $reloaded->getText()->translate('en_GB')); + self::assertSame('new extra text', $reloaded->getExtraText()->translate('en_GB')); + } +} + +/** + * @ORM\Entity() + * + * @ORM\InheritanceType(value="SINGLE_TABLE") + * + * @ORM\DiscriminatorMap({"base"="EntityInheritance_BaseEntityClass", "child"="EntityInheritance_ChildEntityClass"}) + * + * @ORM\DiscriminatorColumn(name="discriminator", type="string") + * + * @Polyglot\Locale(primary="en_GB") + */ +class EntityInheritance_BaseEntityClass +{ + /** + * @ORM\Column(type="integer") + * + * @ORM\Id + * + * @ORM\GeneratedValue + */ + private ?int $id = null; + + private string $discriminator; + + /** + * @ORM\OneToMany(targetEntity="EntityInheritance_BaseEntityClassTranslation", mappedBy="entity") + * + * @Polyglot\TranslationCollection + */ + private Collection $translations; + + /** + * @ORM\Column(type="string") + * + * @Polyglot\Translatable + */ + private TranslatableInterface|string|null $text = null; + + public function __construct() + { + $this->translations = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } + + public function setText(TranslatableInterface $text): void + { + $this->text = $text; + } + + public function getText(): TranslatableInterface + { + return $this->text; + } +} + +/** + * @ORM\Entity + */ +class EntityInheritance_BaseEntityClassTranslation +{ + /** + * @ORM\Id + * + * @ORM\GeneratedValue + * + * @ORM\Column(type="integer") + */ + private ?int $id = null; + + /** + * @ORM\Column + * + * @Polyglot\Locale + */ + private string $locale; + + /** + * @ORM\ManyToOne(targetEntity="EntityInheritance_BaseEntityClass", inversedBy="translations") + */ + private EntityInheritance_BaseEntityClass $entity; + + /** + * @ORM\Column() + */ + private string $text; +} + +/** + * @ORM\Entity + */ +class EntityInheritance_ChildEntityClass extends EntityInheritance_BaseEntityClass +{ + /** + * @ORM\Column(type="string") + * + * @Polyglot\Translatable + */ + private TranslatableInterface|string|null $extraText = null; + + /** + * @ORM\OneToMany(targetEntity="EntityInheritance_ChildEntityClassTranslation", mappedBy="entity") + * + * @Polyglot\TranslationCollection + */ + private Collection $extraTranslations; + + public function __construct() + { + parent::__construct(); + $this->extraTranslations = new ArrayCollection(); + } + + public function setExtra(TranslatableInterface $extraText): void + { + $this->extraText = $extraText; + } + + public function getExtraText(): TranslatableInterface + { + return $this->extraText; + } +} + +/** + * @ORM\Entity + */ +class EntityInheritance_ChildEntityClassTranslation +{ + /** + * @ORM\Id + * + * @ORM\GeneratedValue + * + * @ORM\Column(type="integer") + */ + private ?int $id = null; + + /** + * @ORM\Column + * + * @Polyglot\Locale + */ + private string $locale; + + /** + * @ORM\ManyToOne(targetEntity="EntityInheritance_ChildEntityClass", inversedBy="extraTranslations") + */ + private EntityInheritance_ChildEntityClass $entity; + + /** + * @ORM\Column() + */ + private string $extraText; +} diff --git a/tests/Functional/FunctionalTestBase.php b/tests/Functional/FunctionalTestBase.php new file mode 100644 index 0000000..600737f --- /dev/null +++ b/tests/Functional/FunctionalTestBase.php @@ -0,0 +1,28 @@ +infrastructure = ORMInfrastructure::createOnlyFor($classes); + $this->entityManager = $this->infrastructure->getEntityManager(); + $this->defaultLocaleProvider = new DefaultLocaleProvider('en_GB'); + + $this->entityManager->getEventManager()->addEventSubscriber( + new PolyglotListener(new AnnotationReader(), $this->defaultLocaleProvider) + ); + } +} diff --git a/tests/IntegrationTest.php b/tests/Functional/IntegrationTest.php similarity index 66% rename from tests/IntegrationTest.php rename to tests/Functional/IntegrationTest.php index 1eec7d8..e71e801 100644 --- a/tests/IntegrationTest.php +++ b/tests/Functional/IntegrationTest.php @@ -1,36 +1,19 @@ infrastructure = ORMInfrastructure::createOnlyFor([TestEntity::class, TestEntityTranslation::class]); - $this->entityManager = $this->infrastructure->getEntityManager(); - $this->defaultLocaleProvider = new DefaultLocaleProvider('en_GB'); - - $listener = new PolyglotListener(new AnnotationReader(), $this->defaultLocaleProvider); - $this->entityManager->getEventManager()->addEventListener( - ['postFlush', 'prePersist', 'preFlush', 'postLoad'], - $listener - ); + $this->setupOrmInfrastructure([TestEntity::class, TestEntityTranslation::class]); } public function testPersistingEntityWithPlainStringInTranslatableField(): void @@ -38,7 +21,7 @@ public function testPersistingEntityWithPlainStringInTranslatableField(): void $entity = new TestEntity('text'); $this->infrastructure->import($entity); - $entity = $this->clearAndRefetch($entity); + $entity = $this->fetch($entity); self::assertEquals('text', $entity->getText()); } @@ -47,7 +30,7 @@ public function testPersistingEntityWithTranslatableInstanceInTranslatableField( $entity = new TestEntity(new Translatable('text')); $this->infrastructure->import($entity); - $entity = $this->clearAndRefetch($entity); + $entity = $this->fetch($entity); self::assertEquals('text', $entity->getText()); } @@ -80,7 +63,7 @@ public function testTranslationsAreImplicitlyPersistedForNewEntitiy(): void $this->infrastructure->import($newEntity); // just import the "main" entity, which has no 'cascade="..."' configuration - $newEntity = $this->clearAndRefetch($newEntity); + $newEntity = $this->fetch($newEntity); self::assertEquals('text de_DE', $newEntity->getText()->translate('de_DE')); // translation is available, must have been persisted in the DB } @@ -90,15 +73,15 @@ public function testNewTranslationsAreImplicitlyPersistedForManagedEntitiy(): vo $managedEntity->getText()->setTranslation('text xx_XX', 'xx_XX'); $this->entityManager->flush(); + $this->entityManager->clear(); - $managedEntity = $this->clearAndRefetch($managedEntity); + $managedEntity = $this->fetch($managedEntity); self::assertEquals('text xx_XX', $managedEntity->getText()->translate('xx_XX')); // Translation still there, must come from DB } public function testEntityConsideredCleanWhenNoTranslationWasChanged(): void { $entity = $this->createAndFetchTestEntity(); - $queryCount = \count($this->getQueries()); /* @@ -107,26 +90,39 @@ public function testEntityConsideredCleanWhenNoTranslationWasChanged(): void injected proxies before Doctrine performs the change detection. Afterwards, all proxies should be put back in place. */ - $this->infrastructure->getEntityManager()->flush(); + $this->entityManager->flush(); $queries = $this->getQueries(); - self::assertEquals($queryCount, \count($queries)); + self::assertCount($queryCount, $queries); + self::assertInstanceOf(TranslatableInterface::class, $entity->getText()); + } + + public function testEntityConsideredCleanWhenTranslationUpdateWasNoRealChange(): void + { + $entity = $this->createAndFetchTestEntity(); + $queryCount = \count($this->getQueries()); + + // Effectively, this changes nothing: + $entity->getText()->setTranslation('some change', 'en_GB'); + $entity->getText()->setTranslation('text en_GB', 'en_GB'); + $this->infrastructure->getEntityManager()->flush(); + $queries = $this->getQueries(); + self::assertCount($queryCount, $queries); self::assertInstanceOf(TranslatableInterface::class, $entity->getText()); } private function createAndFetchTestEntity(): TestEntity { $entity = $this->createTestEntity(); - $this->infrastructure->import([$entity]); - $this->entityManager->clear(); // work around https://github.com/webfactory/doctrine-orm-test-infrastructure/issues/23 - - /** @var TestEntity $persistedEntity */ - $persistedEntity = $this->entityManager->find(TestEntity::class, $entity->getId()); + $this->infrastructure->import($entity); - return $persistedEntity; + return $this->fetch($entity); } + /** + * @return Query[] + */ private function getQueries(): array { return $this->infrastructure->getQueries(); @@ -141,11 +137,8 @@ private function createTestEntity(): TestEntity return new TestEntity($translatable); } - private function clearAndRefetch(TestEntity $entity): TestEntity + private function fetch(TestEntity $entity) { - $id = $entity->getId(); - $this->entityManager->clear(); // forget about all entities - - return $this->entityManager->find(TestEntity::class, $id); // Clean state, fetch entity from DB again + return $this->entityManager->find(TestEntity::class, $entity->getId()); // Clean state, fetch entity from DB again } } diff --git a/tests/Functional/TranslationPropertyNamedDifferentlyTest.php b/tests/Functional/TranslationPropertyNamedDifferentlyTest.php new file mode 100644 index 0000000..82a0ca0 --- /dev/null +++ b/tests/Functional/TranslationPropertyNamedDifferentlyTest.php @@ -0,0 +1,164 @@ +setupOrmInfrastructure([ + TranslationPropertyNamedDifferently_Entity::class, + TranslationPropertyNamedDifferently_Translation::class, + ]); + } + + public function testPersistAndReloadEntity(): void + { + $entity = new TranslationPropertyNamedDifferently_Entity(); + $translatable = new Translatable('base text'); + $translatable->setTranslation('Basistext', 'de_DE'); + $entity->setText($translatable); + + $this->infrastructure->import($entity); + + $loaded = $this->infrastructure->getEntityManager()->find(TranslationPropertyNamedDifferently_Entity::class, $entity->getId()); + + self::assertSame('Basistext', $loaded->getText()->translate('de_DE')); + self::assertSame('base text', $loaded->getText()->translate('en_GB')); + } + + public function testAddTranslation(): void + { + $entityManager = $this->infrastructure->getEntityManager(); + $entity = new TranslationPropertyNamedDifferently_Entity(); + $entity->setText(new Translatable('base text')); + $this->infrastructure->import($entity); + + $loaded = $entityManager->find(TranslationPropertyNamedDifferently_Entity::class, $entity->getId()); + $loaded->getText()->setTranslation('Basistext', 'de_DE'); + $entityManager->flush(); + + $entityManager->clear(); + $reloaded = $entityManager->find(TranslationPropertyNamedDifferently_Entity::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->infrastructure->getEntityManager(); + + $entity = new TranslationPropertyNamedDifferently_Entity(); + $translatable = new Translatable('base text'); + $translatable->setTranslation('Basistext', 'de_DE'); + $entity->setText($translatable); + $this->infrastructure->import($entity); + + $loaded = $entityManager->find(TranslationPropertyNamedDifferently_Entity::class, $entity->getId()); + $loaded->getText()->setTranslation('new base text'); + $loaded->getText()->setTranslation('neuer Basistext', 'de_DE'); + $entityManager->flush(); + + $entityManager->clear(); + $reloaded = $entityManager->find(TranslationPropertyNamedDifferently_Entity::class, $entity->getId()); + + self::assertSame('new base text', $reloaded->getText()->translate('en_GB')); + self::assertSame('neuer Basistext', $reloaded->getText()->translate('de_DE')); + } +} + +/** + * @ORM\Entity + * + * @Polyglot\Locale(primary="en_GB") + */ +class TranslationPropertyNamedDifferently_Entity +{ + /** + * @ORM\Column(type="integer") + * + * @ORM\Id + * + * @ORM\GeneratedValue + */ + private ?int $id = null; + + /** + * @ORM\OneToMany(targetEntity="TranslationPropertyNamedDifferently_Translation", mappedBy="entity") + * + * @Polyglot\TranslationCollection + */ + protected Collection $translations; + + /** + * @ORM\Column(type="string") + * + * @Polyglot\Translatable(translationFieldname="textOtherName") + */ + protected string|TranslatableInterface|null $text = null; + + public function __construct() + { + $this->translations = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } + + public function setText(TranslatableInterface $text): void + { + $this->text = $text; + } + + public function getText(): ?TranslatableInterface + { + return $this->text; + } +} + +/** + * @ORM\Entity + */ +class TranslationPropertyNamedDifferently_Translation +{ + /** + * @ORM\Id + * + * @ORM\GeneratedValue + * + * @ORM\Column(type="integer") + */ + private ?int $id = null; + + /** + * @ORM\Column + * + * @Polyglot\Locale + */ + private string $locale; + + /** + * @ORM\ManyToOne(targetEntity="TranslationPropertyNamedDifferently_Entity", inversedBy="translations") + */ + private TranslationPropertyNamedDifferently_Entity $entity; + + /** + * @ORM\Column + */ + private string $textOtherName; +} diff --git a/tests/Functional/UndeclaredBaseClassTest.php b/tests/Functional/UndeclaredBaseClassTest.php new file mode 100644 index 0000000..96f2ba4 --- /dev/null +++ b/tests/Functional/UndeclaredBaseClassTest.php @@ -0,0 +1,175 @@ +setupOrmInfrastructure([ + UndeclaredBaseClassTest_EntityClass::class, + UndeclaredBaseClassTest_BaseClassTranslation::class, + ]); + } + + public function testPersistAndReloadEntity(): void + { + $entity = new UndeclaredBaseClassTest_EntityClass(); + $t1 = new Translatable('base text'); + $t1->setTranslation('Basistext', 'de_DE'); + $entity->setText($t1); + + $this->infrastructure->import($entity); + + $loaded = $this->infrastructure->getEntityManager()->find(UndeclaredBaseClassTest_EntityClass::class, $entity->getId()); + + self::assertSame('Basistext', $loaded->getText()->translate('de_DE')); + self::assertSame('base text', $loaded->getText()->translate('en_GB')); + } + + public function testAddTranslation(): void + { + $entityManager = $this->infrastructure->getEntityManager(); + $entity = new UndeclaredBaseClassTest_EntityClass(); + $entity->setText(new Translatable('base text')); + $this->infrastructure->import($entity); + + $loaded = $entityManager->find(UndeclaredBaseClassTest_EntityClass::class, $entity->getId()); + $loaded->getText()->setTranslation('Basistext', 'de_DE'); + $entityManager->flush(); + + $entityManager->clear(); + $reloaded = $entityManager->find(UndeclaredBaseClassTest_EntityClass::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->infrastructure->getEntityManager(); + + $entity = new UndeclaredBaseClassTest_EntityClass(); + $t1 = new Translatable('base text'); + $t1->setTranslation('Basistext', 'de_DE'); + $entity->setText($t1); + $this->infrastructure->import($entity); + + $loaded = $entityManager->find(UndeclaredBaseClassTest_EntityClass::class, $entity->getId()); + $loaded->getText()->setTranslation('new base text'); + $loaded->getText()->setTranslation('neuer Basistext', 'de_DE'); + $entityManager->flush(); + + $entityManager->clear(); + $reloaded = $entityManager->find(UndeclaredBaseClassTest_EntityClass::class, $entity->getId()); + + self::assertSame('new base text', $reloaded->getText()->translate('en_GB')); + self::assertSame('neuer Basistext', $reloaded->getText()->translate('de_DE')); + } +} + +/** + * Fields in this class cannot be "private" - otherwise, they would not be picked up by the + * Doctrine mapping drivers when processing the entity sub-class (UndeclaredBaseClassTest_EntityClass). + */ +class UndeclaredBaseClassTest_BaseClass +{ + /** + * @ORM\Column(type="integer") + * + * @ORM\Id + * + * @ORM\GeneratedValue + */ + protected ?int $id = null; + + /** + * @ORM\OneToMany(targetEntity="UndeclaredBaseClassTest_BaseClassTranslation", mappedBy="entity") + * + * @Polyglot\TranslationCollection + */ + protected Collection $translations; + + /** + * @ORM\Column(type="string") + * + * @Polyglot\Translatable + */ + protected string|TranslatableInterface|null $text = null; + + public function __construct() + { + $this->translations = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } + + public function setText(TranslatableInterface $text): void + { + $this->text = $text; + } + + public function getText(): ?TranslatableInterface + { + return $this->text; + } +} + +/** + * @ORM\Entity + */ +class UndeclaredBaseClassTest_BaseClassTranslation +{ + /** + * @ORM\Id + * + * @ORM\GeneratedValue + * + * @ORM\Column(type="integer") + */ + private ?int $id = null; + + /** + * @ORM\Column + * + * @Polyglot\Locale + */ + private string $locale; + + /** + * @ORM\ManyToOne(targetEntity="UndeclaredBaseClassTest_EntityClass", inversedBy="translations") + */ + private UndeclaredBaseClassTest_EntityClass $entity; + + /** + * @ORM\Column + */ + private string $text; +} + +/** + * @ORM\Entity + * + * @Polyglot\Locale(primary="en_GB") + */ +class UndeclaredBaseClassTest_EntityClass extends UndeclaredBaseClassTest_BaseClass +{ +} diff --git a/tests/TestEntity.php b/tests/TestEntity.php index 75cdf35..f41ec3b 100644 --- a/tests/TestEntity.php +++ b/tests/TestEntity.php @@ -29,7 +29,7 @@ class TestEntity * * @ORM\Column(type="integer") * - * @ORM\GeneratedValue(strategy="AUTO") + * @ORM\GeneratedValue */ private ?int $id = null; From cbcd1b61e9a72d8ff359f6c6a2552e49a7d81048 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 8 Jan 2024 10:08:43 +0100 Subject: [PATCH 2/3] Improve error messages, simplify Symfony DIC config --- src/Doctrine/TranslatableClassMetadata.php | 11 ++++---- src/Resources/config/services.xml | 30 ++++------------------ 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/src/Doctrine/TranslatableClassMetadata.php b/src/Doctrine/TranslatableClassMetadata.php index 1973177..81f0e25 100644 --- a/src/Doctrine/TranslatableClassMetadata.php +++ b/src/Doctrine/TranslatableClassMetadata.php @@ -93,7 +93,7 @@ public static function parseFromClass(string $class, Reader $reader, ClassMetada $tm->findTranslationsCollection($cm, $reader, $classMetadataFactory); $tm->findTranslatedProperties($cm, $reader, $classMetadataFactory); - if ($tm->assertNoAnnotationsArePresent()) { + if ($tm->isClassWithoutTranslations()) { return null; } $tm->assertAnnotationsAreComplete($class); @@ -149,19 +149,18 @@ public static function wakeup(SerializedTranslatableClassMetadata $data): self return $self; } - private function assertNoAnnotationsArePresent(): bool + private function isClassWithoutTranslations(): bool { return null === $this->translationClass && null === $this->translationLocaleProperty && null === $this->translationMappingProperty - && 0 === \count($this->translatedProperties) - && null === $this->primaryLocale; + && 0 === \count($this->translatedProperties); } private function assertAnnotationsAreComplete(string $class): void { if (null === $this->translationClass) { - throw new RuntimeException('The annotation with the translation class name is missing or incorrect, e.g. @ORM\OneToMany(targetEntity="TestEntityTranslation", ...)'); + throw new RuntimeException(sprintf('Unable to find the translations for %s. There should be a one-to-may collection holding the translation entities, and it should be marked with %s.', $class, Annotation\TranslationCollection::class)); } if (null === $this->translationLocaleProperty) { @@ -177,7 +176,7 @@ private function assertAnnotationsAreComplete(string $class): void } if (null === $this->primaryLocale) { - throw new RuntimeException('A primary locale has to be set at the class level for '.$class); + throw new RuntimeException(sprintf('Class %s uses translations, so it needs to provide the primary locale with the %s annotation at the class level. This can either be at the class itself, or in one of its parent classes.', $class, Annotation\Locale::class)); } } diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 6ecfdb2..67ef312 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -3,40 +3,20 @@ - - Webfactory\Bundle\PolyglotBundle\Doctrine\PolyglotListener - Webfactory\Bundle\PolyglotBundle\EventListener\LocaleListener - Webfactory\Bundle\PolyglotBundle\Locale\DefaultLocaleProvider - - - - - - - - - - - + + + - - - - + - + %webfactory.polyglot.default_locale% - From 1708210f02a7c3629b81fc5e6753d8adc06d58c7 Mon Sep 17 00:00:00 2001 From: Matthias Pigulla Date: Mon, 8 Jan 2024 16:14:23 +0100 Subject: [PATCH 3/3] Add missing requirement --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 0efdd5d..876556b 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "php": "8.1.*|8.2.*|8.3.*", "doctrine/annotations": "^1.12", "doctrine/collections": "^1.0", + "doctrine/event-manager": "^1.0", "doctrine/orm": "^2.10", "doctrine/persistence": "^1.3.8 | ^2.1", "psr/log": "^1.0",