Skip to content

Commit

Permalink
Improve and simplify integration with ORM internals
Browse files Browse the repository at this point in the history
  • Loading branch information
mpdude committed Jan 8, 2024
1 parent 2500871 commit e93b161
Show file tree
Hide file tree
Showing 14 changed files with 960 additions and 335 deletions.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
195 changes: 98 additions & 97 deletions src/Doctrine/PersistentTranslatable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array<string, object|null>>
* @var array<class-string, array<int, array<string, object|null>>>
*/
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);
}

Expand All @@ -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;
}
Expand All @@ -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);
}
}

Expand All @@ -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;
}
Expand All @@ -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);
Expand All @@ -200,61 +214,48 @@ 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<object> 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();
}

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(
Expand All @@ -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) {
Expand Down
Loading

0 comments on commit e93b161

Please sign in to comment.